e4d8476782
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.
330 lines
12 KiB
C#
330 lines
12 KiB
C#
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();
|
|
}
|
|
}
|