WIP(initial-implementation): parallel batch P2/P3/P5 — code complete, unreviewed
Snapshot of the parallel batch (Phases 2 + 3 + 5) at session pause. Solution does
NOT build cleanly yet — known cross-phase compile issues remain to be resolved
before review. See plans/initial-implementation/PLAN.md "Resume Notes" section
for the exact tomorrow-morning action list.
Phase 2 (Storage):
- Repository interfaces in Marathon.Application/Abstractions
- DateRange, ExportKind, StorageOptions in Marathon.Application/Storage
- EF Core 8 + SQLite (WAL) persistence: 7 entities + configurations + 4 repos
- Hand-written InitialCreate migration (dotnet ef blocked by parallel work)
- ClosedXML ExcelExporter with exact customer-spec wide columns
- PersistenceModule.AddMarathonPersistence DI extension
- Round-trip + export tests (cannot run yet — see cross-phase issues)
Phase 3 (Scraping):
- IOddsScraper, IBetPlacer in Marathon.Application/Abstractions
- ScrapingOptions in Marathon.Infrastructure/Configuration
- MarathonbetScraper with 4 parsers (Upcoming, Live, EventOdds, Results)
- Helpers: ServerTimeProvider, PeriodScopeMapper, OutcomeCodeMapper, MoscowDateParser
- UserAgentRotatorHandler + Polly v8 resilience pipeline
- ScrapingModule.AddMarathonScraping DI extension
- GlobalUsings.cs aliases for EventId / Configuration disambiguation
- Parser tests with trimmed HTML fixtures
- ScrapeResultsAsync interim no-op (Phase 8 will replace via watch-list polling)
Phase 5 (UI shell — killed mid-final-verify, assumed ~95%):
- Marathon.UI populated: MainLayout, App.razor, Pages (Home, Settings),
Components, Theme (MarathonTheme.cs + Tokens.cs + app.css), Resources
(SharedResource.{cs,ru.resx,en.resx}), Services (ISettingsWriter), wwwroot
- WPF host: App.xaml(.cs), MainWindow.xaml(.cs), Marathon.Hosts.WpfBlazor.csproj
with Microsoft.AspNetCore.Components.WebView.Wpf + MudBlazor + Serilog
- appsettings.json + appsettings.Development.json with all sections wired
- bUnit tests: MainLayoutTests, LocaleSwitcherTests, ThemeToggleTests,
JsonSettingsWriterTests + Support helpers
Cross-phase issues to resolve at next session:
1. Phase 2 repository classes are 'internal' — Phase 3's tests can't reference
them. Fix: add InternalsVisibleTo to Marathon.Infrastructure.csproj.
2. Phase 5: LocalizationOptions namespace ambiguity (AspNetCore vs Extensions).
3. Phase 5: WpfBlazor Serilog API mismatch.
Reviewer has NOT run on this batch. Move to Phase 4 only after build is green
and a combined parallel-batch reviewer passes.
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
using ClosedXML.Excel;
|
||||
using FluentAssertions;
|
||||
using Marathon.Application.Storage;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Marathon.Infrastructure.Export;
|
||||
using Marathon.Infrastructure.Persistence;
|
||||
using Marathon.Infrastructure.Persistence.Entities;
|
||||
using Marathon.Infrastructure.Tests.Persistence;
|
||||
|
||||
namespace Marathon.Infrastructure.Tests.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ExcelExporter"/> — verifies sheet names, header row, row count,
|
||||
/// filename pattern and WinnerSide computation.
|
||||
/// </summary>
|
||||
public sealed class ExcelExporterTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
|
||||
private readonly InMemoryDbFixture _fixture;
|
||||
private readonly ExcelExporter _exporter;
|
||||
private readonly string _outputDir;
|
||||
|
||||
public ExcelExporterTests()
|
||||
{
|
||||
_fixture = new InMemoryDbFixture();
|
||||
_exporter = new ExcelExporter(_fixture.DbContext);
|
||||
_outputDir = Path.Combine(Path.GetTempPath(), $"marathon_export_test_{Guid.NewGuid():N}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fixture.Dispose();
|
||||
if (Directory.Exists(_outputDir))
|
||||
Directory.Delete(_outputDir, recursive: true);
|
||||
}
|
||||
|
||||
// ── Filename pattern ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Export_Filename_MatchesDateRangePattern()
|
||||
{
|
||||
// Arrange
|
||||
await SeedThreeEvents();
|
||||
|
||||
var range = new DateRange(
|
||||
new DateTimeOffset(2026, 5, 1, 0, 0, 0, MoscowOffset),
|
||||
new DateTimeOffset(2026, 5, 31, 23, 59, 59, MoscowOffset));
|
||||
|
||||
// Act
|
||||
var path = await _exporter.ExportAsync(range, ExportKind.Combined, _outputDir);
|
||||
|
||||
// Assert
|
||||
var fileName = Path.GetFileName(path);
|
||||
fileName.Should().Be("Marathon_2026-05-01_to_2026-05-31.xlsx");
|
||||
File.Exists(path).Should().BeTrue();
|
||||
}
|
||||
|
||||
// ── Sheet names for Combined ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Export_Combined_ProducesTwoSheets_PreMatchAndLive()
|
||||
{
|
||||
// Arrange
|
||||
await SeedThreeEvents();
|
||||
var range = FullRange();
|
||||
|
||||
// Act
|
||||
var path = await _exporter.ExportAsync(range, ExportKind.Combined, _outputDir);
|
||||
|
||||
// Assert
|
||||
using var wb = new XLWorkbook(path);
|
||||
wb.Worksheets.Should().HaveCount(2);
|
||||
wb.Worksheets.Select(ws => ws.Name).Should().Contain("PreMatch").And.Contain("Live");
|
||||
}
|
||||
|
||||
// ── Sheet names for PreMatch-only ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Export_PreMatchOnly_ProducesOneSheet()
|
||||
{
|
||||
// Arrange
|
||||
await SeedThreeEvents();
|
||||
var range = FullRange();
|
||||
|
||||
// Act
|
||||
var path = await _exporter.ExportAsync(range, ExportKind.PreMatch, _outputDir);
|
||||
|
||||
// Assert
|
||||
using var wb = new XLWorkbook(path);
|
||||
wb.Worksheets.Should().HaveCount(1);
|
||||
wb.Worksheets.First().Name.Should().Be("PreMatch");
|
||||
}
|
||||
|
||||
// ── Header row matches canonical column list ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Export_HeaderRow_MatchesCanonicalColumnOrder()
|
||||
{
|
||||
// Arrange
|
||||
await SeedThreeEvents();
|
||||
var range = FullRange();
|
||||
|
||||
// Act
|
||||
var path = await _exporter.ExportAsync(range, ExportKind.PreMatch, _outputDir);
|
||||
|
||||
// Assert
|
||||
using var wb = new XLWorkbook(path);
|
||||
var sheet = wb.Worksheets.First();
|
||||
var headers = GetHeaderRow(sheet);
|
||||
|
||||
// Metadata columns
|
||||
headers.Should().StartWith(new[]
|
||||
{
|
||||
"RowNum", "SportCode", "Sport", "Country", "League", "Category",
|
||||
"DateFull", "Day", "Month", "Year", "Time", "EventId",
|
||||
});
|
||||
|
||||
// Match-level bet columns (Bet_ prefix for PreMatch)
|
||||
headers.Should().Contain("Bet_Match_Win_1");
|
||||
headers.Should().Contain("Bet_Match_Draw");
|
||||
headers.Should().Contain("Bet_Match_Win_2");
|
||||
headers.Should().Contain("Bet_Match_Win_Fora_1_Value");
|
||||
headers.Should().Contain("Bet_Match_Win_Fora_1_Rate");
|
||||
headers.Should().Contain("Bet_Match_Win_Fora_2_Value");
|
||||
headers.Should().Contain("Bet_Match_Win_Fora_2_Rate");
|
||||
headers.Should().Contain("Bet_Match_Total_Less_Value");
|
||||
headers.Should().Contain("Bet_Match_Total_Less_Rate");
|
||||
headers.Should().Contain("Bet_Match_Total_More_Value");
|
||||
headers.Should().Contain("Bet_Match_Total_More_Rate");
|
||||
|
||||
// Period columns (we seeded period-1 bets)
|
||||
headers.Should().Contain("Bet_Period-1_Win_1");
|
||||
headers.Should().Contain("Bet_Period-1_Win_2");
|
||||
|
||||
// Trailing WinnerSide
|
||||
headers.Last().Should().Be("WinnerSide");
|
||||
}
|
||||
|
||||
// ── Live sheet uses Live_ prefix ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Export_LiveSheet_UsesLivePrefix()
|
||||
{
|
||||
// Arrange
|
||||
await SeedThreeEvents();
|
||||
var range = FullRange();
|
||||
|
||||
// Act
|
||||
var path = await _exporter.ExportAsync(range, ExportKind.Live, _outputDir);
|
||||
|
||||
// Assert
|
||||
using var wb = new XLWorkbook(path);
|
||||
var sheet = wb.Worksheets.First();
|
||||
var headers = GetHeaderRow(sheet);
|
||||
|
||||
headers.Should().Contain("Live_Match_Win_1");
|
||||
headers.Should().NotContain("Bet_Match_Win_1");
|
||||
}
|
||||
|
||||
// ── Row count equals event count ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Export_RowCount_MatchesSnapshotCount()
|
||||
{
|
||||
// Arrange: 3 pre-match snapshots
|
||||
await SeedThreeEvents();
|
||||
var range = FullRange();
|
||||
|
||||
// Act
|
||||
var path = await _exporter.ExportAsync(range, ExportKind.PreMatch, _outputDir);
|
||||
|
||||
// Assert
|
||||
using var wb = new XLWorkbook(path);
|
||||
var sheet = wb.Worksheets.First();
|
||||
// Row 1 = header; rows 2..N = data
|
||||
var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1;
|
||||
(lastRow - 1).Should().Be(3); // 3 pre-match snapshots seeded
|
||||
}
|
||||
|
||||
// ── WinnerSide computation ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Export_WinnerSide_IsOne_WhenWin1RateLowerThanWin2()
|
||||
{
|
||||
// Arrange: single event where win1=1.65 < win2=2.20
|
||||
await SeedSingleEventWithKnownRates(win1: 1.65m, win2: 2.20m);
|
||||
var range = FullRange();
|
||||
|
||||
// Act
|
||||
var path = await _exporter.ExportAsync(range, ExportKind.PreMatch, _outputDir);
|
||||
|
||||
// Assert
|
||||
using var wb = new XLWorkbook(path);
|
||||
var sheet = wb.Worksheets.First();
|
||||
var headers = GetHeaderRow(sheet);
|
||||
var winnerSideCol = headers.IndexOf("WinnerSide") + 1; // 1-based
|
||||
var cellValue = sheet.Cell(2, winnerSideCol).Value;
|
||||
cellValue.IsNumber.Should().BeTrue();
|
||||
cellValue.GetNumber().Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_WinnerSide_IsTwo_WhenWin2RateLowerThanWin1()
|
||||
{
|
||||
// Arrange: single event where win1=2.50 > win2=1.70
|
||||
await SeedSingleEventWithKnownRates(win1: 2.50m, win2: 1.70m);
|
||||
var range = FullRange();
|
||||
|
||||
// Act
|
||||
var path = await _exporter.ExportAsync(range, ExportKind.PreMatch, _outputDir);
|
||||
|
||||
// Assert
|
||||
using var wb = new XLWorkbook(path);
|
||||
var sheet = wb.Worksheets.First();
|
||||
var headers = GetHeaderRow(sheet);
|
||||
var winnerSideCol = headers.IndexOf("WinnerSide") + 1;
|
||||
var cellValue = sheet.Cell(2, winnerSideCol).Value;
|
||||
cellValue.IsNumber.Should().BeTrue();
|
||||
cellValue.GetNumber().Should().Be(2);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private DateRange FullRange() =>
|
||||
new(
|
||||
new DateTimeOffset(2026, 1, 1, 0, 0, 0, MoscowOffset),
|
||||
new DateTimeOffset(2026, 12, 31, 23, 59, 59, MoscowOffset));
|
||||
|
||||
private static List<string> GetHeaderRow(IXLWorksheet sheet)
|
||||
{
|
||||
var headers = new List<string>();
|
||||
var lastCol = sheet.LastColumnUsed()?.ColumnNumber() ?? 0;
|
||||
for (var col = 1; col <= lastCol; col++)
|
||||
headers.Add(sheet.Cell(1, col).GetString());
|
||||
return headers;
|
||||
}
|
||||
|
||||
/// <summary>Seeds 3 pre-match and 1 live snapshot across 3 events.</summary>
|
||||
private async Task SeedThreeEvents()
|
||||
{
|
||||
var capturedAt = new DateTimeOffset(2026, 5, 10, 12, 0, 0, MoscowOffset);
|
||||
var scheduledAt = new DateTimeOffset(2026, 5, 10, 20, 30, 0, MoscowOffset);
|
||||
|
||||
for (var i = 1; i <= 3; i++)
|
||||
{
|
||||
var eventEntity = new EventEntity
|
||||
{
|
||||
EventCode = $"E{i:D4}",
|
||||
SportCode = 11,
|
||||
CountryCode = "England",
|
||||
LeagueId = "premier-league",
|
||||
Category = string.Empty,
|
||||
ScheduledAt = scheduledAt.ToString("O"),
|
||||
Side1Name = $"Home{i}",
|
||||
Side2Name = $"Away{i}",
|
||||
};
|
||||
_fixture.DbContext.Events.Add(eventEntity);
|
||||
|
||||
// Pre-match snapshot
|
||||
var preMatch = new SnapshotEntity
|
||||
{
|
||||
EventCode = $"E{i:D4}",
|
||||
CapturedAt = capturedAt.ToString("O"),
|
||||
Source = (int)OddsSource.PreMatch,
|
||||
Bets =
|
||||
[
|
||||
new BetEntity { Scope = 0, Type = (int)BetType.Win, Side = (int)Side.Side1, Rate = 1.85m },
|
||||
new BetEntity { Scope = 0, Type = (int)BetType.Draw, Side = (int)Side.Draw, Rate = 3.50m },
|
||||
new BetEntity { Scope = 0, Type = (int)BetType.Win, Side = (int)Side.Side2, Rate = 4.20m },
|
||||
new BetEntity { Scope = 1, PeriodNumber = 1, Type = (int)BetType.Win, Side = (int)Side.Side1, Rate = 2.10m },
|
||||
],
|
||||
};
|
||||
_fixture.DbContext.Snapshots.Add(preMatch);
|
||||
}
|
||||
|
||||
// One live snapshot on event 1
|
||||
var liveSnapshot = new SnapshotEntity
|
||||
{
|
||||
EventCode = "E0001",
|
||||
CapturedAt = capturedAt.AddHours(1).ToString("O"),
|
||||
Source = (int)OddsSource.Live,
|
||||
Bets =
|
||||
[
|
||||
new BetEntity { Scope = 0, Type = (int)BetType.Win, Side = (int)Side.Side1, Rate = 1.90m },
|
||||
new BetEntity { Scope = 0, Type = (int)BetType.Win, Side = (int)Side.Side2, Rate = 3.80m },
|
||||
],
|
||||
};
|
||||
_fixture.DbContext.Snapshots.Add(liveSnapshot);
|
||||
|
||||
await _fixture.DbContext.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
}
|
||||
|
||||
private async Task SeedSingleEventWithKnownRates(decimal win1, decimal win2)
|
||||
{
|
||||
var scheduledAt = new DateTimeOffset(2026, 5, 20, 18, 0, 0, MoscowOffset);
|
||||
var capturedAt = new DateTimeOffset(2026, 5, 20, 10, 0, 0, MoscowOffset);
|
||||
|
||||
_fixture.DbContext.Events.Add(new EventEntity
|
||||
{
|
||||
EventCode = "W0001",
|
||||
SportCode = 11,
|
||||
CountryCode = "Spain",
|
||||
LeagueId = "la-liga",
|
||||
Category = string.Empty,
|
||||
ScheduledAt = scheduledAt.ToString("O"),
|
||||
Side1Name = "Real Madrid",
|
||||
Side2Name = "Barcelona",
|
||||
});
|
||||
|
||||
_fixture.DbContext.Snapshots.Add(new SnapshotEntity
|
||||
{
|
||||
EventCode = "W0001",
|
||||
CapturedAt = capturedAt.ToString("O"),
|
||||
Source = (int)OddsSource.PreMatch,
|
||||
Bets =
|
||||
[
|
||||
new BetEntity { Scope = 0, Type = (int)BetType.Win, Side = (int)Side.Side1, Rate = win1 },
|
||||
new BetEntity { Scope = 0, Type = (int)BetType.Win, Side = (int)Side.Side2, Rate = win2 },
|
||||
],
|
||||
});
|
||||
|
||||
await _fixture.DbContext.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
initData = {"serverTime":"2026,05,06,02,10,00","timeZoneId":"Europe/Moscow"};
|
||||
//]]>
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<ol class="breadcrumbs-list">
|
||||
<li class="breadcrumbs-item"><a href="/su/"><span class="breadcrumb-text">Ставки на спорт</span></a></li>
|
||||
<li class="breadcrumbs-item"><a href="/su/betting/Basketball+-+6"><span class="breadcrumb-text">Ставки на Баскетбол</span></a></li>
|
||||
<li class="breadcrumbs-item"><a href="/su/betting/Basketball/USA+-+9876"><span class="breadcrumb-text">США</span></a></li>
|
||||
<li class="breadcrumbs-item"><a href="/su/betting/Basketball/USA/NBA+-+321"><span class="breadcrumb-text">NBA</span></a></li>
|
||||
<li class="breadcrumbs-item"><span class="breadcrumb-text">Нью-Йорк Никс - Филадельфия 76ерс</span></li>
|
||||
</ol>
|
||||
|
||||
<div class=" coupon-row" data-event-eventId="26769028" data-event-treeId="28405506"
|
||||
data-event-name="Нью-Йорк Никс - Филадельфия 76ерс" data-live="false">
|
||||
<table class="coupon-row-item">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="hidden" data-mutable-id="eventJsonInfo"
|
||||
data-json="{"treeId":28405506,"marathonEventId":26769028,"teamNames":["Нью-Йорк Никс","Филадельфия 76ерс"],"matchTime":{"seconds":0,"finalScore":false,"isOvertime":false},"eventInningTimes":[],"inningScore":[],"overTimeInningScore":[],"currentInning":-1,"serve":0,"resultDescription":"","matchIsComplete":false}">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Basketball: Match Winner Including All OT (2-way, no draw) -->
|
||||
<td data-market-type="RESULT">
|
||||
<span data-selection-price="1.35" data-selection-key="26769028@Match_Winner_Including_All_OT.HB_H">1.35</span>
|
||||
</td>
|
||||
<td data-market-type="RESULT">
|
||||
<span data-selection-price="3.22" data-selection-key="26769028@Match_Winner_Including_All_OT.HB_A">3.22</span>
|
||||
</td>
|
||||
|
||||
<!-- Handicap -->
|
||||
<td data-market-type="HANDICAP">
|
||||
(-5.5)<br/>
|
||||
<span data-selection-price="1.909" data-selection-key="26769028@To_Win_Match_With_Handicap.HB_H">1.909</span>
|
||||
</td>
|
||||
<td data-market-type="HANDICAP">
|
||||
(+5.5)<br/>
|
||||
<span data-selection-price="1.909" data-selection-key="26769028@To_Win_Match_With_Handicap.HB_A">1.909</span>
|
||||
</td>
|
||||
|
||||
<!-- Total Points -->
|
||||
<td data-market-type="TOTAL">
|
||||
(213.5)<br/>
|
||||
<span data-selection-price="1.870" data-selection-key="26769028@Total_Points10.Under_213.5">1.870</span>
|
||||
</td>
|
||||
<td data-market-type="TOTAL">
|
||||
(213.5)<br/>
|
||||
<span data-selection-price="1.909" data-selection-key="26769028@Total_Points10.Over_213.5">1.909</span>
|
||||
</td>
|
||||
|
||||
<!-- Period 1 (1st Half) Win -->
|
||||
<span data-selection-price="1.55" data-selection-key="26769028@1st_Half_Result0.RN_H">1.55</span>
|
||||
<span data-selection-price="2.60" data-selection-key="26769028@1st_Half_Result0.RN_A">2.60</span>
|
||||
|
||||
<!-- Period 2 (2nd Half) Win -->
|
||||
<span data-selection-price="1.60" data-selection-key="26769028@2nd_Half_Result0.RN_H">1.60</span>
|
||||
<span data-selection-price="2.30" data-selection-key="26769028@2nd_Half_Result0.RN_A">2.30</span>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
initData = {"serverTime":"2026,05,05,22,30,00","timeZoneId":"Europe/Moscow"};
|
||||
//]]>
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Completed event with matchIsComplete=true -->
|
||||
<div class=" coupon-row" data-event-eventId="26456100" data-event-treeId="28089600"
|
||||
data-event-name="Реал Мадрид - Барселона" data-live="false">
|
||||
<table class="coupon-row-item">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="hidden" data-mutable-id="eventJsonInfo"
|
||||
data-json="{"treeId":28089600,"marathonEventId":26456100,"teamNames":["Реал Мадрид","Барселона"],"matchTime":{"seconds":5400,"finalScore":true,"isOvertime":false},"eventInningTimes":[],"inningScore":[{"home":"2","away":"1","inning":1},{"home":"1","away":"0","inning":2}],"overTimeInningScore":[],"currentInning":-1,"serve":0,"resultDescription":"3:1 (2:1)","matchIsComplete":true}">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
initData = {"serverTime":"2026,05,05,00,42,28","timeZoneId":"Europe/Moscow"};
|
||||
//]]>
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<ol class="breadcrumbs-list">
|
||||
<li class="breadcrumbs-item"><a href="/su/"><span class="breadcrumb-text">Ставки на спорт</span></a></li>
|
||||
<li class="breadcrumbs-item"><a href="/su/betting/Football+-+11"><span class="breadcrumb-text">Ставки на Футбол</span></a></li>
|
||||
<li class="breadcrumbs-item"><a href="/su/betting/Football/Clubs.+International+-+4409575"><span class="breadcrumb-text">Клубы. Международные</span></a></li>
|
||||
<li class="breadcrumbs-item"><a href="/su/betting/Football/Clubs.+International/UEFA+Champions+League+-+21255"><span class="breadcrumb-text">Лига чемпионов УЕФА</span></a></li>
|
||||
<li class="breadcrumbs-item"><span class="breadcrumb-text">Арсенал - Атлетико Мадрид</span></li>
|
||||
</ol>
|
||||
|
||||
<div class=" coupon-row" data-event-eventId="26456117" data-event-treeId="28089645"
|
||||
data-event-name="Арсенал - Атлетико Мадрид" data-live="false">
|
||||
<table class="coupon-row-item">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="hidden" data-mutable-id="eventJsonInfo"
|
||||
data-json="{"treeId":28089645,"marathonEventId":26456117,"teamNames":["Арсенал","Атлетико Мадрид"],"matchTime":{"seconds":0,"finalScore":false,"isOvertime":false},"eventInningTimes":[],"inningScore":[],"overTimeInningScore":[],"currentInning":-1,"serve":0,"resultDescription":"","matchIsComplete":false}">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Match Result (1x2) -->
|
||||
<td data-market-type="RESULT">
|
||||
<span data-selection-price="1.65" data-selection-key="26456117@Match_Result.1">1.65</span>
|
||||
</td>
|
||||
<td data-market-type="RESULT">
|
||||
<span data-selection-price="4.1" data-selection-key="26456117@Match_Result.draw">4.10</span>
|
||||
</td>
|
||||
<td data-market-type="RESULT">
|
||||
<span data-selection-price="5.7" data-selection-key="26456117@Match_Result.3">5.70</span>
|
||||
</td>
|
||||
|
||||
<!-- Handicap -->
|
||||
<td data-market-type="HANDICAP">
|
||||
(-1.0)<br/>
|
||||
<span data-selection-price="2.04" data-selection-key="26456117@To_Win_Match_With_Handicap.HB_H">2.04</span>
|
||||
</td>
|
||||
<td data-market-type="HANDICAP">
|
||||
(+1.0)<br/>
|
||||
<span data-selection-price="1.82" data-selection-key="26456117@To_Win_Match_With_Handicap.HB_A">1.82</span>
|
||||
</td>
|
||||
|
||||
<!-- Total Goals -->
|
||||
<td data-market-type="TOTAL">
|
||||
(2.5)<br/>
|
||||
<span data-selection-price="1.92" data-selection-key="26456117@Total_Goals.Under_2.5">1.92</span>
|
||||
</td>
|
||||
<td data-market-type="TOTAL">
|
||||
(2.5)<br/>
|
||||
<span data-selection-price="1.92" data-selection-key="26456117@Total_Goals.Over_2.5">1.92</span>
|
||||
</td>
|
||||
|
||||
<!-- Period 1 (1st Half) Result -->
|
||||
<span data-selection-price="1.80" data-selection-key="26456117@Result_-_1st_Half.RN_H">1.80</span>
|
||||
<span data-selection-price="3.60" data-selection-key="26456117@Result_-_1st_Half.RN_D">3.60</span>
|
||||
<span data-selection-price="4.20" data-selection-key="26456117@Result_-_1st_Half.RN_A">4.20</span>
|
||||
|
||||
<!-- Period 2 (2nd Half) Result -->
|
||||
<span data-selection-price="2.10" data-selection-key="26456117@Result_-_2nd_Half.RN_H">2.10</span>
|
||||
<span data-selection-price="2.80" data-selection-key="26456117@Result_-_2nd_Half.RN_D">2.80</span>
|
||||
<span data-selection-price="3.50" data-selection-key="26456117@Result_-_2nd_Half.RN_A">3.50</span>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
initData = {"serverTime":"2026,05,05,00,42,28","timeZoneId":"Europe/Moscow"};
|
||||
//]]>
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div data-sport-treeId="11" class="sport-category-container">
|
||||
<div class=" coupon-row" data-event-eventId="26456117" data-event-treeId="28089645"
|
||||
data-event-name="Арсенал - Атлетико Мадрид" data-live="false"
|
||||
data-event-path="Football/Clubs.+International/UEFA+Champions+League/Play-Offs/Semi+Final/2nd+Leg/Arsenal+vs+Atletico+Madrid+-+28089645">
|
||||
<table class="coupon-row-item">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="coupon-subrow-container">
|
||||
<div class="date-wrapper">06 мая 22:00</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div data-sport-treeId="6" class="sport-category-container">
|
||||
<div class=" coupon-row" data-event-eventId="26769028" data-event-treeId="28405506"
|
||||
data-event-name="Нью-Йорк Никс - Филадельфия 76ерс" data-live="false"
|
||||
data-event-path="Basketball/USA/NBA/Play-Offs/Semi-Finals/New+York+Knicks+vs+Philadelphia+76ers+-+28405506">
|
||||
<table class="coupon-row-item">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="coupon-subrow-container">
|
||||
<div class="date-wrapper">07 мая 02:30</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div data-sport-treeId="22723" class="sport-category-container">
|
||||
<div class=" coupon-row" data-event-eventId="26800001" data-event-treeId="28430484"
|
||||
data-event-name="Тиафо - Алькарас" data-live="false"
|
||||
data-event-path="Tennis/International/ATP+Rome/Qualifying/Round+1/Tiafoe+vs+Alcaraz+-+28430484">
|
||||
<table class="coupon-row-item">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="coupon-subrow-container">
|
||||
<div class="date-wrapper">06 мая 10:00</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,9 +9,11 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -22,4 +24,11 @@
|
||||
<ProjectReference Include="..\..\src\Marathon.Infrastructure\Marathon.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Copy HTML fixtures to output directory so tests can read them via AppContext.BaseDirectory -->
|
||||
<ItemGroup>
|
||||
<Content Include="Fixtures\marathonbet\*.html">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Marathon.Infrastructure.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Marathon.Infrastructure.Tests.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Shared in-memory SQLite fixture for persistence tests.
|
||||
/// Uses a named in-memory database with a shared cache connection so the schema
|
||||
/// created in <c>EnsureCreated()</c> is visible across the lifetime of each test.
|
||||
/// </summary>
|
||||
public sealed class InMemoryDbFixture : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _keepAliveConnection;
|
||||
|
||||
public MarathonDbContext DbContext { get; }
|
||||
|
||||
public InMemoryDbFixture()
|
||||
{
|
||||
// Keep a single connection open so the in-memory DB is not dropped between
|
||||
// DbContext operations. Cache=Shared ensures the same DB is reused.
|
||||
_keepAliveConnection = new SqliteConnection("Data Source=marathon_tests;Mode=Memory;Cache=Shared");
|
||||
_keepAliveConnection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<MarathonDbContext>()
|
||||
.UseSqlite("Data Source=marathon_tests;Mode=Memory;Cache=Shared")
|
||||
.Options;
|
||||
|
||||
DbContext = new MarathonDbContext(options);
|
||||
DbContext.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DbContext.Dispose();
|
||||
_keepAliveConnection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Marathon.Infrastructure.Persistence;
|
||||
using Marathon.Infrastructure.Persistence.Repositories;
|
||||
|
||||
namespace Marathon.Infrastructure.Tests.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip persistence tests: insert domain objects → retrieve → assert field equality.
|
||||
/// Uses an in-memory SQLite database per test class via InMemoryDbFixture.
|
||||
/// </summary>
|
||||
public sealed class RoundTripTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
|
||||
private readonly InMemoryDbFixture _fixture;
|
||||
private readonly EventRepository _eventRepo;
|
||||
private readonly SnapshotRepository _snapshotRepo;
|
||||
private readonly ResultRepository _resultRepo;
|
||||
private readonly AnomalyRepository _anomalyRepo;
|
||||
|
||||
public RoundTripTests()
|
||||
{
|
||||
_fixture = new InMemoryDbFixture();
|
||||
_eventRepo = new EventRepository(_fixture.DbContext);
|
||||
_snapshotRepo = new SnapshotRepository(_fixture.DbContext);
|
||||
_resultRepo = new ResultRepository(_fixture.DbContext);
|
||||
_anomalyRepo = new AnomalyRepository(_fixture.DbContext);
|
||||
}
|
||||
|
||||
public void Dispose() => _fixture.Dispose();
|
||||
|
||||
// ── Event round-trip ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Event_RoundTrip_PreservesAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var evt = new Event(
|
||||
Id: new EventId("26456117"),
|
||||
Sport: new SportCode(11),
|
||||
CountryCode: "England",
|
||||
LeagueId: "premier-league",
|
||||
Category: "Play-Offs",
|
||||
ScheduledAt: new DateTimeOffset(2026, 5, 10, 20, 30, 0, MoscowOffset),
|
||||
Side1Name: "Arsenal",
|
||||
Side2Name: "Chelsea");
|
||||
|
||||
// Act
|
||||
await _eventRepo.AddAsync(evt);
|
||||
await _eventRepo.SaveChangesAsync();
|
||||
|
||||
// Detach so the next read hits the DB
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
|
||||
var retrieved = await _eventRepo.GetAsync(new EventId("26456117"));
|
||||
|
||||
// Assert
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.Id.Value.Should().Be("26456117");
|
||||
retrieved.Sport.Value.Should().Be(11);
|
||||
retrieved.CountryCode.Should().Be("England");
|
||||
retrieved.LeagueId.Should().Be("premier-league");
|
||||
retrieved.Category.Should().Be("Play-Offs");
|
||||
retrieved.ScheduledAt.Should().Be(new DateTimeOffset(2026, 5, 10, 20, 30, 0, MoscowOffset));
|
||||
retrieved.ScheduledAt.Offset.Should().Be(MoscowOffset);
|
||||
retrieved.Side1Name.Should().Be("Arsenal");
|
||||
retrieved.Side2Name.Should().Be("Chelsea");
|
||||
}
|
||||
|
||||
// ── OddsSnapshot round-trip ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task OddsSnapshot_RoundTrip_PreservesAllBets()
|
||||
{
|
||||
// Arrange — persist event first (FK constraint)
|
||||
var evt = BuildEvent("99001");
|
||||
await _eventRepo.AddAsync(evt);
|
||||
await _eventRepo.SaveChangesAsync();
|
||||
|
||||
var snapshot = new OddsSnapshot(
|
||||
eventId: new EventId("99001"),
|
||||
capturedAt: new DateTimeOffset(2026, 5, 10, 18, 0, 0, MoscowOffset),
|
||||
source: OddsSource.PreMatch,
|
||||
bets: new List<Bet>
|
||||
{
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.85m)),
|
||||
new(MatchScope.Instance, BetType.Draw, Side.Draw, null, new OddsRate(3.50m)),
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(4.20m)),
|
||||
new(MatchScope.Instance, BetType.WinFora, Side.Side1, new OddsValue(-1.5m), new OddsRate(2.10m)),
|
||||
new(MatchScope.Instance, BetType.Total, Side.Less, new OddsValue(2.5m), new OddsRate(1.95m)),
|
||||
new(new PeriodScope(1), BetType.Win, Side.Side1, null, new OddsRate(2.30m)),
|
||||
}.AsReadOnly());
|
||||
|
||||
// Act
|
||||
await _snapshotRepo.AddAsync(snapshot);
|
||||
await _snapshotRepo.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
|
||||
var snapshots = await _snapshotRepo.ListByEventAsync(
|
||||
new EventId("99001"),
|
||||
DateTimeOffset.MinValue,
|
||||
DateTimeOffset.MaxValue);
|
||||
|
||||
// Assert
|
||||
snapshots.Should().HaveCount(1);
|
||||
var retrieved = snapshots[0];
|
||||
retrieved.EventId.Value.Should().Be("99001");
|
||||
retrieved.Source.Should().Be(OddsSource.PreMatch);
|
||||
retrieved.Bets.Should().HaveCount(6);
|
||||
|
||||
// Spot-check individual bets
|
||||
var win1 = retrieved.Bets.Single(b => b.Scope is MatchScope && b.Type == BetType.Win && b.Side == Side.Side1);
|
||||
win1.Rate.Value.Should().Be(1.85m);
|
||||
win1.Value.Should().BeNull();
|
||||
|
||||
var fora = retrieved.Bets.Single(b => b.Type == BetType.WinFora && b.Side == Side.Side1);
|
||||
fora.Value!.Value.Should().Be(-1.5m);
|
||||
fora.Rate.Value.Should().Be(2.10m);
|
||||
|
||||
var period1Win1 = retrieved.Bets.Single(b => b.Scope is PeriodScope { Number: 1 } && b.Type == BetType.Win);
|
||||
period1Win1.Rate.Value.Should().Be(2.30m);
|
||||
}
|
||||
|
||||
// ── BetScope round-trip ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BetScope_RoundTrip_MatchScopeAndPeriodScope()
|
||||
{
|
||||
// Arrange
|
||||
var evt = BuildEvent("99002");
|
||||
await _eventRepo.AddAsync(evt);
|
||||
await _eventRepo.SaveChangesAsync();
|
||||
|
||||
var snapshot = new OddsSnapshot(
|
||||
eventId: new EventId("99002"),
|
||||
capturedAt: new DateTimeOffset(2026, 5, 11, 10, 0, 0, MoscowOffset),
|
||||
source: OddsSource.Live,
|
||||
bets: new List<Bet>
|
||||
{
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.50m)),
|
||||
new(new PeriodScope(2), BetType.Win, Side.Side2, null, new OddsRate(2.75m)),
|
||||
}.AsReadOnly());
|
||||
|
||||
// Act
|
||||
await _snapshotRepo.AddAsync(snapshot);
|
||||
await _snapshotRepo.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
|
||||
var snapshots = await _snapshotRepo.ListByEventAsync(
|
||||
new EventId("99002"),
|
||||
DateTimeOffset.MinValue,
|
||||
DateTimeOffset.MaxValue);
|
||||
|
||||
// Assert
|
||||
var bets = snapshots[0].Bets;
|
||||
bets.Should().HaveCount(2);
|
||||
|
||||
var matchBet = bets.Single(b => b.Scope is MatchScope);
|
||||
matchBet.Scope.Should().BeOfType<MatchScope>();
|
||||
matchBet.Rate.Value.Should().Be(1.50m);
|
||||
|
||||
var periodBet = bets.Single(b => b.Scope is PeriodScope);
|
||||
var ps = periodBet.Scope.Should().BeOfType<PeriodScope>().Subject;
|
||||
ps.Number.Should().Be(2);
|
||||
periodBet.Rate.Value.Should().Be(2.75m);
|
||||
}
|
||||
|
||||
// ── EventResult round-trip ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task EventResult_RoundTrip_PreservesAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var evt = BuildEvent("99003");
|
||||
await _eventRepo.AddAsync(evt);
|
||||
await _eventRepo.SaveChangesAsync();
|
||||
|
||||
var result = new EventResult(
|
||||
EventId: new EventId("99003"),
|
||||
Side1Score: 2,
|
||||
Side2Score: 1,
|
||||
WinnerSide: Side.Side1,
|
||||
CompletedAt: new DateTimeOffset(2026, 5, 10, 22, 45, 0, MoscowOffset));
|
||||
|
||||
// Act
|
||||
await _resultRepo.AddAsync(result);
|
||||
await _resultRepo.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
|
||||
var retrieved = await _resultRepo.GetAsync(new EventId("99003"));
|
||||
|
||||
// Assert
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.EventId.Value.Should().Be("99003");
|
||||
retrieved.Side1Score.Should().Be(2);
|
||||
retrieved.Side2Score.Should().Be(1);
|
||||
retrieved.WinnerSide.Should().Be(Side.Side1);
|
||||
retrieved.CompletedAt.Offset.Should().Be(MoscowOffset);
|
||||
}
|
||||
|
||||
// ── Anomaly round-trip ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Anomaly_RoundTrip_PreservesAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var evt = BuildEvent("99004");
|
||||
await _eventRepo.AddAsync(evt);
|
||||
await _eventRepo.SaveChangesAsync();
|
||||
|
||||
var anomalyId = Guid.NewGuid();
|
||||
var anomaly = new Anomaly(
|
||||
Id: anomalyId,
|
||||
EventId: new EventId("99004"),
|
||||
DetectedAt: new DateTimeOffset(2026, 5, 10, 19, 0, 0, MoscowOffset),
|
||||
Kind: AnomalyKind.SuspensionFlip,
|
||||
Score: 0.87m,
|
||||
EvidenceJson: "{\"snapshots\":[1,2,3]}");
|
||||
|
||||
// Act
|
||||
await _anomalyRepo.AddAsync(anomaly);
|
||||
await _anomalyRepo.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
|
||||
var retrieved = await _anomalyRepo.GetAsync(anomalyId);
|
||||
|
||||
// Assert
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.Id.Should().Be(anomalyId);
|
||||
retrieved.EventId.Value.Should().Be("99004");
|
||||
retrieved.Kind.Should().Be(AnomalyKind.SuspensionFlip);
|
||||
retrieved.Score.Should().Be(0.87m);
|
||||
retrieved.EvidenceJson.Should().Be("{\"snapshots\":[1,2,3]}");
|
||||
}
|
||||
|
||||
// ── ListByDateRange ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ListByDateRange_ReturnsOnlyEventsInRange()
|
||||
{
|
||||
// Arrange: three events at different times
|
||||
var e1 = BuildEvent("R001", new DateTimeOffset(2026, 5, 1, 12, 0, 0, MoscowOffset));
|
||||
var e2 = BuildEvent("R002", new DateTimeOffset(2026, 5, 5, 18, 0, 0, MoscowOffset));
|
||||
var e3 = BuildEvent("R003", new DateTimeOffset(2026, 5, 10, 20, 0, 0, MoscowOffset));
|
||||
|
||||
await _eventRepo.AddAsync(e1);
|
||||
await _eventRepo.AddAsync(e2);
|
||||
await _eventRepo.AddAsync(e3);
|
||||
await _eventRepo.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
|
||||
var range = new Marathon.Application.Storage.DateRange(
|
||||
new DateTimeOffset(2026, 5, 3, 0, 0, 0, MoscowOffset),
|
||||
new DateTimeOffset(2026, 5, 7, 0, 0, 0, MoscowOffset));
|
||||
|
||||
// Act
|
||||
var results = await _eventRepo.ListByDateRangeAsync(range);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
results[0].Id.Value.Should().Be("R002");
|
||||
}
|
||||
|
||||
// ── WAL mode ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Database_WalPragma_ExecutesWithoutError()
|
||||
{
|
||||
// In-memory SQLite does not support WAL (always returns "memory"),
|
||||
// but the PRAGMA command must execute without throwing an exception.
|
||||
// This test verifies the plumbing works — file-mode WAL is tested at runtime.
|
||||
var exception = await Record.ExceptionAsync(async () =>
|
||||
await _fixture.DbContext.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;"));
|
||||
|
||||
exception.Should().BeNull("PRAGMA journal_mode=WAL should execute without error");
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static Event BuildEvent(string id, DateTimeOffset? scheduledAt = null) =>
|
||||
new(
|
||||
Id: new EventId(id),
|
||||
Sport: new SportCode(11),
|
||||
CountryCode: "England",
|
||||
LeagueId: "premier-league",
|
||||
Category: string.Empty,
|
||||
ScheduledAt: scheduledAt ?? new DateTimeOffset(2026, 5, 10, 20, 0, 0, TimeSpan.FromHours(3)),
|
||||
Side1Name: "Home",
|
||||
Side2Name: "Away");
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Infrastructure.Scraping.Parsers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Marathon.Infrastructure.Tests.Scraping;
|
||||
|
||||
public sealed class EventOddsParserTests
|
||||
{
|
||||
private static string FixturePath(string filename) => Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Fixtures", "marathonbet", filename);
|
||||
|
||||
private readonly EventOddsParser _sut;
|
||||
|
||||
public EventOddsParserTests()
|
||||
{
|
||||
var serverTime = new ServerTimeProvider(NullLogger<ServerTimeProvider>.Instance);
|
||||
var periodMapper = new PeriodScopeMapper(basketballQuarterMode: false);
|
||||
_sut = new EventOddsParser(
|
||||
serverTime,
|
||||
periodMapper,
|
||||
NullLogger<EventOddsParser>.Instance);
|
||||
}
|
||||
|
||||
// ── Football fixture ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_FootballFixture_ReturnsNonNullSnapshot()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-football-sample.html"));
|
||||
|
||||
var snapshot = await _sut.ParseAsync(html, OddsSource.PreMatch);
|
||||
|
||||
snapshot.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_FootballFixture_SnapshotEventIdMatches()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-football-sample.html"));
|
||||
|
||||
var snapshot = await _sut.ParseAsync(html, OddsSource.PreMatch);
|
||||
|
||||
snapshot!.EventId.Value.Should().Be("26456117");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_FootballFixture_MatchWin1Extracted()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-football-sample.html"));
|
||||
|
||||
var snapshot = await _sut.ParseAsync(html, OddsSource.PreMatch);
|
||||
|
||||
var win1 = snapshot!.Bets.FirstOrDefault(b =>
|
||||
b.Scope is MatchScope && b.Type == BetType.Win && b.Side == Side.Side1);
|
||||
|
||||
win1.Should().NotBeNull("Match Win-1 bet must be present");
|
||||
win1!.Rate.Value.Should().Be(1.65m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_FootballFixture_MatchDrawExtracted()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-football-sample.html"));
|
||||
|
||||
var snapshot = await _sut.ParseAsync(html, OddsSource.PreMatch);
|
||||
|
||||
var draw = snapshot!.Bets.FirstOrDefault(b =>
|
||||
b.Scope is MatchScope && b.Type == BetType.Draw);
|
||||
|
||||
draw.Should().NotBeNull("Match Draw bet must be present");
|
||||
draw!.Rate.Value.Should().Be(4.1m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_FootballFixture_MatchWin2Extracted()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-football-sample.html"));
|
||||
|
||||
var snapshot = await _sut.ParseAsync(html, OddsSource.PreMatch);
|
||||
|
||||
var win2 = snapshot!.Bets.FirstOrDefault(b =>
|
||||
b.Scope is MatchScope && b.Type == BetType.Win && b.Side == Side.Side2);
|
||||
|
||||
win2.Should().NotBeNull("Match Win-2 bet must be present");
|
||||
win2!.Rate.Value.Should().Be(5.7m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_FootballFixture_HandicapBetsExtracted()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-football-sample.html"));
|
||||
|
||||
var snapshot = await _sut.ParseAsync(html, OddsSource.PreMatch);
|
||||
|
||||
var fora1 = snapshot!.Bets.FirstOrDefault(b =>
|
||||
b.Scope is MatchScope && b.Type == BetType.WinFora && b.Side == Side.Side1);
|
||||
var fora2 = snapshot.Bets.FirstOrDefault(b =>
|
||||
b.Scope is MatchScope && b.Type == BetType.WinFora && b.Side == Side.Side2);
|
||||
|
||||
fora1.Should().NotBeNull("Handicap Side1 must be present");
|
||||
fora2.Should().NotBeNull("Handicap Side2 must be present");
|
||||
|
||||
fora1!.Value!.Value.Should().Be(-1.0m);
|
||||
fora1.Rate.Value.Should().Be(2.04m);
|
||||
|
||||
fora2!.Value!.Value.Should().Be(1.0m);
|
||||
fora2.Rate.Value.Should().Be(1.82m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_FootballFixture_TotalBetsExtracted()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-football-sample.html"));
|
||||
|
||||
var snapshot = await _sut.ParseAsync(html, OddsSource.PreMatch);
|
||||
|
||||
var totalLess = snapshot!.Bets.FirstOrDefault(b =>
|
||||
b.Scope is MatchScope && b.Type == BetType.Total && b.Side == Side.Less);
|
||||
var totalMore = snapshot.Bets.FirstOrDefault(b =>
|
||||
b.Scope is MatchScope && b.Type == BetType.Total && b.Side == Side.More);
|
||||
|
||||
totalLess.Should().NotBeNull("Total Less must be present");
|
||||
totalMore.Should().NotBeNull("Total More must be present");
|
||||
|
||||
totalLess!.Value!.Value.Should().Be(2.5m);
|
||||
totalMore!.Value!.Value.Should().Be(2.5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_FootballFixture_Period1WinExtracted()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-football-sample.html"));
|
||||
|
||||
var snapshot = await _sut.ParseAsync(html, OddsSource.PreMatch);
|
||||
|
||||
var p1Win1 = snapshot!.Bets.FirstOrDefault(b =>
|
||||
b.Scope is PeriodScope { Number: 1 } && b.Type == BetType.Win && b.Side == Side.Side1);
|
||||
var p1Draw = snapshot.Bets.FirstOrDefault(b =>
|
||||
b.Scope is PeriodScope { Number: 1 } && b.Type == BetType.Draw);
|
||||
var p1Win2 = snapshot.Bets.FirstOrDefault(b =>
|
||||
b.Scope is PeriodScope { Number: 1 } && b.Type == BetType.Win && b.Side == Side.Side2);
|
||||
|
||||
p1Win1.Should().NotBeNull("Period-1 Win-1 must be present for football");
|
||||
p1Draw.Should().NotBeNull("Period-1 Draw must be present for football");
|
||||
p1Win2.Should().NotBeNull("Period-1 Win-2 must be present for football");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_FootballFixture_SourceIsStamped()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-football-sample.html"));
|
||||
|
||||
var snapshot = await _sut.ParseAsync(html, OddsSource.Live);
|
||||
|
||||
snapshot!.Source.Should().Be(OddsSource.Live);
|
||||
}
|
||||
|
||||
// ── Basketball fixture ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_BasketballFixture_MatchWin1WithNoDrawExtracted()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-basketball-sample.html"));
|
||||
|
||||
var snapshot = await _sut.ParseAsync(html, OddsSource.PreMatch);
|
||||
|
||||
snapshot.Should().NotBeNull();
|
||||
|
||||
var win1 = snapshot!.Bets.FirstOrDefault(b =>
|
||||
b.Scope is MatchScope && b.Type == BetType.Win && b.Side == Side.Side1);
|
||||
var draw = snapshot.Bets.FirstOrDefault(b =>
|
||||
b.Scope is MatchScope && b.Type == BetType.Draw);
|
||||
|
||||
win1.Should().NotBeNull("Basketball Match Win-1 must be present");
|
||||
draw.Should().BeNull("Basketball (OT market) has no Draw outcome");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_BasketballFixture_Period1WinsExtracted()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-basketball-sample.html"));
|
||||
|
||||
var snapshot = await _sut.ParseAsync(html, OddsSource.PreMatch);
|
||||
|
||||
var p1Win1 = snapshot!.Bets.FirstOrDefault(b =>
|
||||
b.Scope is PeriodScope { Number: 1 } && b.Type == BetType.Win && b.Side == Side.Side1);
|
||||
var p1Win2 = snapshot.Bets.FirstOrDefault(b =>
|
||||
b.Scope is PeriodScope { Number: 1 } && b.Type == BetType.Win && b.Side == Side.Side2);
|
||||
|
||||
p1Win1.Should().NotBeNull("Basketball Period-1 Win-1 must be present");
|
||||
p1Win2.Should().NotBeNull("Basketball Period-1 Win-2 must be present");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Infrastructure.Scraping.Parsers;
|
||||
|
||||
namespace Marathon.Infrastructure.Tests.Scraping;
|
||||
|
||||
public sealed class MoscowDateParserTests
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
|
||||
// Server time anchor: 2026-05-05 00:42:28 Moscow
|
||||
private static readonly DateTimeOffset Anchor =
|
||||
new(2026, 5, 5, 0, 42, 28, MoscowOffset);
|
||||
|
||||
[Fact]
|
||||
public void TryParse_TimeOnlyFormat_UsesAnchorDateWithParsedTime()
|
||||
{
|
||||
// "03:00" → today's date from anchor + 03:00
|
||||
var result = MoscowDateParser.TryParse("03:00", Anchor);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.Year.Should().Be(2026);
|
||||
result.Value.Month.Should().Be(5);
|
||||
result.Value.Day.Should().Be(5);
|
||||
result.Value.Hour.Should().Be(3);
|
||||
result.Value.Minute.Should().Be(0);
|
||||
result.Value.Offset.Should().Be(MoscowOffset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_FullDateFormat_ParsesCorrectly()
|
||||
{
|
||||
var result = MoscowDateParser.TryParse("06 мая 22:00", Anchor);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.Year.Should().Be(2026);
|
||||
result.Value.Month.Should().Be(5);
|
||||
result.Value.Day.Should().Be(6);
|
||||
result.Value.Hour.Should().Be(22);
|
||||
result.Value.Minute.Should().Be(0);
|
||||
result.Value.Offset.Should().Be(MoscowOffset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_FullDateWithLeadingSpaces_ParsesCorrectly()
|
||||
{
|
||||
var result = MoscowDateParser.TryParse(" 07 мая 02:30 ", Anchor);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.Day.Should().Be(7);
|
||||
result.Value.Hour.Should().Be(2);
|
||||
result.Value.Minute.Should().Be(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_NullInput_ReturnsNull()
|
||||
{
|
||||
MoscowDateParser.TryParse(null, Anchor).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_EmptyInput_ReturnsNull()
|
||||
{
|
||||
MoscowDateParser.TryParse(string.Empty, Anchor).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_UnrecognizedFormat_ReturnsNull()
|
||||
{
|
||||
MoscowDateParser.TryParse("tomorrow at noon", Anchor).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_YearRollover_AddsOneYear()
|
||||
{
|
||||
// Anchor is Dec 31, event is Jan 1 next year
|
||||
var decAnchor = new DateTimeOffset(2026, 12, 31, 10, 0, 0, MoscowOffset);
|
||||
var result = MoscowDateParser.TryParse("01 января 12:00", decAnchor);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.Year.Should().Be(2027);
|
||||
result.Value.Month.Should().Be(1);
|
||||
result.Value.Day.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_ResultAlwaysHasMoscowOffset()
|
||||
{
|
||||
var result = MoscowDateParser.TryParse("15:30", Anchor);
|
||||
result!.Value.Offset.Should().Be(TimeSpan.FromHours(3));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Infrastructure.Scraping.Parsers;
|
||||
|
||||
namespace Marathon.Infrastructure.Tests.Scraping;
|
||||
|
||||
public sealed class OutcomeCodeMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("1", Side.Side1)]
|
||||
[InlineData("draw", Side.Draw)]
|
||||
[InlineData("3", Side.Side2)]
|
||||
public void TryMap_MatchResultVocabulary_ReturnsExpectedSide(string code, Side expected)
|
||||
{
|
||||
OutcomeCodeMapper.TryMap(code).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RN_H", Side.Side1)]
|
||||
[InlineData("RN_D", Side.Draw)]
|
||||
[InlineData("RN_A", Side.Side2)]
|
||||
public void TryMap_PeriodResultVocabulary_ReturnsExpectedSide(string code, Side expected)
|
||||
{
|
||||
OutcomeCodeMapper.TryMap(code).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("HB_H", Side.Side1)]
|
||||
[InlineData("HB_A", Side.Side2)]
|
||||
public void TryMap_HandicapVocabulary_ReturnsExpectedSide(string code, Side expected)
|
||||
{
|
||||
OutcomeCodeMapper.TryMap(code).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Under_213.5", Side.Less)]
|
||||
[InlineData("Under_3.5", Side.Less)]
|
||||
[InlineData("Over_213.5", Side.More)]
|
||||
[InlineData("Over_3.5", Side.More)]
|
||||
public void TryMap_TotalVocabulary_ReturnsExpectedSide(string code, Side expected)
|
||||
{
|
||||
OutcomeCodeMapper.TryMap(code).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("unknown_code")]
|
||||
[InlineData("HD")]
|
||||
[InlineData("yes")]
|
||||
public void TryMap_UnknownOrEmptyCodes_ReturnsNull(string code)
|
||||
{
|
||||
OutcomeCodeMapper.TryMap(code).Should().BeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Under_213.5", 213.5)]
|
||||
[InlineData("Under_3.5", 3.5)]
|
||||
[InlineData("Over_213.5", 213.5)]
|
||||
[InlineData("Over_3.5", 3.5)]
|
||||
[InlineData("Over_1", 1.0)]
|
||||
public void TryParseTotalThreshold_ValidCodes_ReturnsThreshold(string code, decimal expected)
|
||||
{
|
||||
OutcomeCodeMapper.TryParseTotalThreshold(code).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1")]
|
||||
[InlineData("RN_H")]
|
||||
[InlineData("HB_H")]
|
||||
[InlineData("")]
|
||||
public void TryParseTotalThreshold_NonTotalCodes_ReturnsNull(string code)
|
||||
{
|
||||
OutcomeCodeMapper.TryParseTotalThreshold(code).Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Infrastructure.Scraping.Parsers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Marathon.Infrastructure.Tests.Scraping;
|
||||
|
||||
public sealed class ResultsParserTests
|
||||
{
|
||||
private static string FixturePath(string filename) => Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Fixtures", "marathonbet", filename);
|
||||
|
||||
private readonly ResultsParser _sut = new(NullLogger<ResultsParser>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_CompletedEvent_ReturnsEventResult()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-completed-sample.html"));
|
||||
|
||||
var result = await _sut.ParseAsync(html);
|
||||
|
||||
result.Should().NotBeNull("matchIsComplete=true should yield an EventResult");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_CompletedEvent_EventIdMatches()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-completed-sample.html"));
|
||||
|
||||
var result = await _sut.ParseAsync(html);
|
||||
|
||||
result!.EventId.Value.Should().Be("26456100");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_CompletedEvent_ScoreParsedCorrectly()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-completed-sample.html"));
|
||||
|
||||
var result = await _sut.ParseAsync(html);
|
||||
|
||||
result!.Side1Score.Should().Be(3);
|
||||
result.Side2Score.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_CompletedEvent_WinnerIsSide1()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-completed-sample.html"));
|
||||
|
||||
var result = await _sut.ParseAsync(html);
|
||||
|
||||
result!.WinnerSide.Should().Be(Side.Side1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_IncompleteEvent_ReturnsNull()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath("event-football-sample.html"));
|
||||
|
||||
var result = await _sut.ParseAsync(html);
|
||||
|
||||
result.Should().BeNull("matchIsComplete=false should return null");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_EmptyHtml_ReturnsNull()
|
||||
{
|
||||
var result = await _sut.ParseAsync("<html><body></body></html>");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Infrastructure.Scraping.Parsers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Marathon.Infrastructure.Tests.Scraping;
|
||||
|
||||
public sealed class ServerTimeProviderTests
|
||||
{
|
||||
private readonly ServerTimeProvider _sut = new(NullLogger<ServerTimeProvider>.Instance);
|
||||
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
|
||||
[Fact]
|
||||
public void ExtractServerTime_ValidInitData_ReturnsMoscowTime()
|
||||
{
|
||||
const string html = @"<html><head><script>
|
||||
initData = {""serverTime"":""2026,05,05,00,42,28""};
|
||||
</script></head><body></body></html>";
|
||||
|
||||
var result = _sut.ExtractServerTime(html);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.Year.Should().Be(2026);
|
||||
result.Value.Month.Should().Be(5);
|
||||
result.Value.Day.Should().Be(5);
|
||||
result.Value.Hour.Should().Be(0);
|
||||
result.Value.Minute.Should().Be(42);
|
||||
result.Value.Second.Should().Be(28);
|
||||
result.Value.Offset.Should().Be(MoscowOffset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractServerTime_MissingInitData_ReturnsNull()
|
||||
{
|
||||
const string html = "<html><body>No initData here.</body></html>";
|
||||
_sut.ExtractServerTime(html).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractServerTime_ExtraWhitespaceAroundKey_ParsesCorrectly()
|
||||
{
|
||||
const string html = @"<script>serverTime : ""2026,01,15,08,30,00""</script>";
|
||||
var result = _sut.ExtractServerTime(html);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.Month.Should().Be(1);
|
||||
result.Value.Day.Should().Be(15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Infrastructure.Scraping.Parsers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Marathon.Infrastructure.Tests.Scraping;
|
||||
|
||||
public sealed class UpcomingEventsParserTests
|
||||
{
|
||||
private static readonly string FixturePath = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Fixtures", "marathonbet", "listing-sample.html");
|
||||
|
||||
private readonly UpcomingEventsParser _sut;
|
||||
|
||||
public UpcomingEventsParserTests()
|
||||
{
|
||||
var serverTimeProvider = new ServerTimeProvider(
|
||||
NullLogger<ServerTimeProvider>.Instance);
|
||||
_sut = new UpcomingEventsParser(
|
||||
serverTimeProvider,
|
||||
NullLogger<UpcomingEventsParser>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_SampleListing_ReturnsThreeEvents()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath);
|
||||
|
||||
var events = await _sut.ParseAsync(html);
|
||||
|
||||
events.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_SampleListing_FootballEventHasCorrectSport()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath);
|
||||
|
||||
var events = await _sut.ParseAsync(html);
|
||||
|
||||
var football = events.Single(e => e.Id.Value == "26456117");
|
||||
football.Sport.Value.Should().Be(11); // Football canonical ID
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_SampleListing_BasketballEventHasCorrectSport()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath);
|
||||
|
||||
var events = await _sut.ParseAsync(html);
|
||||
|
||||
var basketball = events.Single(e => e.Id.Value == "26769028");
|
||||
basketball.Sport.Value.Should().Be(6); // Basketball canonical ID
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_SampleListing_EventNamesAreSplit()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath);
|
||||
|
||||
var events = await _sut.ParseAsync(html);
|
||||
|
||||
var football = events.Single(e => e.Id.Value == "26456117");
|
||||
football.Side1Name.Should().Be("Арсенал");
|
||||
football.Side2Name.Should().Be("Атлетико Мадрид");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_SampleListing_ScheduledAtIsMoscowOffset()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath);
|
||||
|
||||
var events = await _sut.ParseAsync(html);
|
||||
|
||||
foreach (var evt in events)
|
||||
{
|
||||
evt.ScheduledAt.Offset.Should().Be(TimeSpan.FromHours(3),
|
||||
"all events must be in Moscow time (UTC+3)");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_SampleListing_FootballEventLeagueExtracted()
|
||||
{
|
||||
var html = await File.ReadAllTextAsync(FixturePath);
|
||||
|
||||
var events = await _sut.ParseAsync(html);
|
||||
|
||||
var football = events.Single(e => e.Id.Value == "26456117");
|
||||
football.LeagueId.Should().Contain("UEFA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_EmptyHtml_ReturnsEmptyList()
|
||||
{
|
||||
const string html = "<html><head><script>initData={\"serverTime\":\"2026,05,05,10,00,00\"}</script></head><body></body></html>";
|
||||
|
||||
var events = await _sut.ParseAsync(html);
|
||||
|
||||
events.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Marathon.UI.Services;
|
||||
|
||||
namespace Marathon.UI.Tests;
|
||||
|
||||
public sealed class JsonSettingsWriterTests : IDisposable
|
||||
{
|
||||
private readonly string _tempPath = Path.Combine(Path.GetTempPath(), $"marathon-settings-{Guid.NewGuid():N}.json");
|
||||
|
||||
[Fact]
|
||||
public async Task Save_writes_section_and_creates_file()
|
||||
{
|
||||
var writer = new JsonSettingsWriter(_tempPath);
|
||||
|
||||
await writer.SaveSectionAsync("Localization", new LocalizationOptions { DefaultCulture = "en-US" });
|
||||
|
||||
File.Exists(_tempPath).Should().BeTrue();
|
||||
var json = await File.ReadAllTextAsync(_tempPath);
|
||||
json.Should().Contain("\"DefaultCulture\"");
|
||||
json.Should().Contain("\"en-US\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_preserves_other_sections()
|
||||
{
|
||||
await File.WriteAllTextAsync(_tempPath, "{\"Untouched\":{\"Value\":42}}");
|
||||
|
||||
var writer = new JsonSettingsWriter(_tempPath);
|
||||
await writer.SaveSectionAsync("Localization", new LocalizationOptions { DefaultCulture = "ru-RU" });
|
||||
|
||||
var root = await writer.ReadAllAsync();
|
||||
root["Untouched"].Should().NotBeNull();
|
||||
root["Untouched"]!["Value"]!.GetValue<int>().Should().Be(42);
|
||||
root["Localization"]!["DefaultCulture"]!.GetValue<string>().Should().Be("ru-RU");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reset_removes_section_only()
|
||||
{
|
||||
var writer = new JsonSettingsWriter(_tempPath);
|
||||
await writer.SaveSectionAsync("A", new { X = 1 });
|
||||
await writer.SaveSectionAsync("B", new { Y = 2 });
|
||||
|
||||
await writer.ResetSectionAsync("A");
|
||||
|
||||
var root = await writer.ReadAllAsync();
|
||||
root.ContainsKey("A").Should().BeFalse();
|
||||
root.ContainsKey("B").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reset_when_file_missing_is_a_no_op()
|
||||
{
|
||||
var writer = new JsonSettingsWriter(_tempPath);
|
||||
await writer.ResetSectionAsync("Anything");
|
||||
File.Exists(_tempPath).Should().BeFalse();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_tempPath))
|
||||
{
|
||||
File.Delete(_tempPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Bunit;
|
||||
using Marathon.UI.Components;
|
||||
using Marathon.UI.Services;
|
||||
using Marathon.UI.Tests.Support;
|
||||
|
||||
namespace Marathon.UI.Tests;
|
||||
|
||||
public sealed class LocaleSwitcherTests : MarathonTestContext
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_to_russian()
|
||||
{
|
||||
var cut = RenderComponent<LocaleSwitcher>();
|
||||
|
||||
var ruButton = cut.FindAll(".m-segmented__btn")[0];
|
||||
var enButton = cut.FindAll(".m-segmented__btn")[1];
|
||||
|
||||
ruButton.ClassList.Should().Contain("is-active");
|
||||
enButton.ClassList.Should().NotContain("is-active");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Switching_to_english_updates_locale_and_persists_setting()
|
||||
{
|
||||
var cut = RenderComponent<LocaleSwitcher>();
|
||||
|
||||
var enButton = cut.FindAll(".m-segmented__btn")[1];
|
||||
await cut.InvokeAsync(() => enButton.Click());
|
||||
|
||||
Locale.Culture.Name.Should().Be(LocaleState.English);
|
||||
System.Globalization.CultureInfo.CurrentUICulture.Name.Should().Be(LocaleState.English);
|
||||
|
||||
Writer.Saved.Should().ContainKey(LocalizationOptions.SectionName);
|
||||
var saved = (LocalizationOptions)Writer.Saved[LocalizationOptions.SectionName];
|
||||
saved.DefaultCulture.Should().Be(LocaleState.English);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Switching_to_already_active_locale_is_a_no_op()
|
||||
{
|
||||
var cut = RenderComponent<LocaleSwitcher>();
|
||||
|
||||
var ruButton = cut.FindAll(".m-segmented__btn")[0];
|
||||
await cut.InvokeAsync(() => ruButton.Click());
|
||||
|
||||
Writer.Saved.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Bunit;
|
||||
using Marathon.UI;
|
||||
using Marathon.UI.Tests.Support;
|
||||
|
||||
namespace Marathon.UI.Tests;
|
||||
|
||||
public sealed class MainLayoutTests : MarathonTestContext
|
||||
{
|
||||
[Fact]
|
||||
public void Renders_brand_and_navigation()
|
||||
{
|
||||
var cut = RenderComponent<MainLayout>(p =>
|
||||
p.Add(layout => layout.Body, b => b.AddMarkupContent(0, "<p data-test=\"slot\">child</p>")));
|
||||
|
||||
// Brand wordmark surfaces from AppBrand.razor → key "App.BrandMark".
|
||||
cut.Markup.Should().Contain("App.BrandMark");
|
||||
|
||||
// Navigation labels appear in the drawer.
|
||||
cut.Markup.Should().Contain("Nav.Dashboard");
|
||||
cut.Markup.Should().Contain("Nav.Settings");
|
||||
|
||||
// Body slot renders.
|
||||
cut.Find("[data-test=slot]").TextContent.Should().Be("child");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Switches_data_theme_when_dark_mode_toggled()
|
||||
{
|
||||
var cut = RenderComponent<MainLayout>();
|
||||
cut.Find(".m-app-frame").GetAttribute("data-theme").Should().Be("light");
|
||||
|
||||
await cut.InvokeAsync(() => Theme.Toggle());
|
||||
|
||||
cut.Find(".m-app-frame").GetAttribute("data-theme").Should().Be("dark");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -12,14 +14,22 @@
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="bunit" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Microsoft.Extensions.Localization" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Marathon.UI\Marathon.UI.csproj" />
|
||||
<ProjectReference Include="..\..\src\Marathon.Domain\Marathon.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\Marathon.Application\Marathon.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// Phase 5/6 will add real tests to this project.
|
||||
namespace Marathon.UI.Tests;
|
||||
|
||||
public sealed class PlaceholderTest
|
||||
{
|
||||
[Fact]
|
||||
public void Placeholder_AlwaysPasses() => Assert.True(true);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Bunit;
|
||||
using Marathon.UI.Resources;
|
||||
using Marathon.UI.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MudBlazor.Services;
|
||||
|
||||
namespace Marathon.UI.Tests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Shared bUnit <see cref="TestContext"/> with the Marathon.UI services
|
||||
/// pre-registered: localizer, theme + locale state, in-memory settings writer,
|
||||
/// MudBlazor services, and a no-op logger.
|
||||
/// </summary>
|
||||
public abstract class MarathonTestContext : TestContext
|
||||
{
|
||||
protected TestSettingsWriter Writer { get; } = new();
|
||||
protected ThemeState Theme { get; } = new();
|
||||
protected LocaleState Locale { get; } = new();
|
||||
|
||||
protected MarathonTestContext()
|
||||
{
|
||||
Services.AddSingleton(Writer);
|
||||
Services.AddSingleton<ISettingsWriter>(Writer);
|
||||
Services.AddSingleton(Theme);
|
||||
Services.AddSingleton(Locale);
|
||||
|
||||
Services.AddSingleton(typeof(IStringLocalizer<>), typeof(TestLocalizer<>));
|
||||
Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
|
||||
Services.AddLogging();
|
||||
|
||||
Services.AddMudServices();
|
||||
|
||||
// bUnit defaults JS interop to Strict; loosen so MudBlazor's interop
|
||||
// calls don't blow up tests that aren't asserting on JS-driven UI.
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
namespace Marathon.UI.Tests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Identity localizer — returns the key as the value. Lets bUnit assertions
|
||||
/// work against the resource keys without loading RESX bundles.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Resource marker type.</typeparam>
|
||||
public sealed class TestLocalizer<T> : IStringLocalizer<T>
|
||||
{
|
||||
public LocalizedString this[string name] => new(name, name, resourceNotFound: false);
|
||||
|
||||
public LocalizedString this[string name, params object[] arguments]
|
||||
=> new(name, string.Format(name, arguments), resourceNotFound: false);
|
||||
|
||||
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Array.Empty<LocalizedString>();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Marathon.UI.Services;
|
||||
|
||||
namespace Marathon.UI.Tests.Support;
|
||||
|
||||
/// <summary>In-memory <see cref="ISettingsWriter"/> for component tests.</summary>
|
||||
public sealed class TestSettingsWriter : ISettingsWriter
|
||||
{
|
||||
public ConcurrentDictionary<string, object> Saved { get; } = new();
|
||||
public ConcurrentBag<string> Reset { get; } = new();
|
||||
|
||||
public Task SaveSectionAsync<T>(string sectionName, T values, CancellationToken cancellationToken = default)
|
||||
where T : class
|
||||
{
|
||||
Saved[sectionName] = values;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ResetSectionAsync(string sectionName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Reset.Add(sectionName);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Bunit;
|
||||
using Marathon.UI.Components;
|
||||
using Marathon.UI.Tests.Support;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using MudBlazor;
|
||||
|
||||
namespace Marathon.UI.Tests;
|
||||
|
||||
public sealed class ThemeToggleTests : MarathonTestContext
|
||||
{
|
||||
[Fact]
|
||||
public void Toggle_flips_theme_state()
|
||||
{
|
||||
var cut = RenderComponent<TestHost>(p => p.AddChildContent<ThemeToggle>());
|
||||
Theme.IsDark.Should().BeFalse();
|
||||
|
||||
cut.Find("button").Click();
|
||||
Theme.IsDark.Should().BeTrue();
|
||||
|
||||
cut.Find("button").Click();
|
||||
Theme.IsDark.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Theme_state_notifies_subscribers_only_on_change()
|
||||
{
|
||||
var notifications = 0;
|
||||
Theme.OnChange += () => notifications++;
|
||||
|
||||
Theme.Set(true);
|
||||
Theme.Set(true); // no-op — already dark
|
||||
Theme.Set(false);
|
||||
|
||||
notifications.Should().Be(2);
|
||||
}
|
||||
|
||||
/// <summary>Wraps the component-under-test with MudBlazor providers
|
||||
/// so MudTooltip/MudPopover initialize correctly.</summary>
|
||||
private sealed class TestHost : ComponentBase
|
||||
{
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder)
|
||||
{
|
||||
builder.OpenComponent<MudPopoverProvider>(0);
|
||||
builder.CloseComponent();
|
||||
builder.AddContent(1, ChildContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user