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:
2026-05-05 01:56:53 +03:00
parent 144c936e90
commit e4d8476782
129 changed files with 8524 additions and 121 deletions
@@ -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="{&quot;treeId&quot;:28405506,&quot;marathonEventId&quot;:26769028,&quot;teamNames&quot;:[&quot;Нью-Йорк Никс&quot;,&quot;Филадельфия 76ерс&quot;],&quot;matchTime&quot;:{&quot;seconds&quot;:0,&quot;finalScore&quot;:false,&quot;isOvertime&quot;:false},&quot;eventInningTimes&quot;:[],&quot;inningScore&quot;:[],&quot;overTimeInningScore&quot;:[],&quot;currentInning&quot;:-1,&quot;serve&quot;:0,&quot;resultDescription&quot;:&quot;&quot;,&quot;matchIsComplete&quot;: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="{&quot;treeId&quot;:28089600,&quot;marathonEventId&quot;:26456100,&quot;teamNames&quot;:[&quot;Реал Мадрид&quot;,&quot;Барселона&quot;],&quot;matchTime&quot;:{&quot;seconds&quot;:5400,&quot;finalScore&quot;:true,&quot;isOvertime&quot;:false},&quot;eventInningTimes&quot;:[],&quot;inningScore&quot;:[{&quot;home&quot;:&quot;2&quot;,&quot;away&quot;:&quot;1&quot;,&quot;inning&quot;:1},{&quot;home&quot;:&quot;1&quot;,&quot;away&quot;:&quot;0&quot;,&quot;inning&quot;:2}],&quot;overTimeInningScore&quot;:[],&quot;currentInning&quot;:-1,&quot;serve&quot;:0,&quot;resultDescription&quot;:&quot;3:1 (2:1)&quot;,&quot;matchIsComplete&quot;: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="{&quot;treeId&quot;:28089645,&quot;marathonEventId&quot;:26456117,&quot;teamNames&quot;:[&quot;Арсенал&quot;,&quot;Атлетико Мадрид&quot;],&quot;matchTime&quot;:{&quot;seconds&quot;:0,&quot;finalScore&quot;:false,&quot;isOvertime&quot;:false},&quot;eventInningTimes&quot;:[],&quot;inningScore&quot;:[],&quot;overTimeInningScore&quot;:[],&quot;currentInning&quot;:-1,&quot;serve&quot;:0,&quot;resultDescription&quot;:&quot;&quot;,&quot;matchIsComplete&quot;: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);
}
}
}