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
+2 -3
View File
@@ -1,9 +1,8 @@
<Application x:Class="Marathon.Hosts.WpfBlazor.App"
<Application x:Class="Marathon.Hosts.WpfBlazor.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Marathon.Hosts.WpfBlazor"
StartupUri="MainWindow.xaml">
ShutdownMode="OnMainWindowClose">
<Application.Resources>
</Application.Resources>
</Application>
+159 -1
View File
@@ -1,10 +1,168 @@
using System.Globalization;
using System.IO;
using System.Windows;
using Marathon.UI.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Serilog;
namespace Marathon.Hosts.WpfBlazor;
/// <summary>
/// Interaction logic for App.xaml
/// WPF application entry-point. Builds an <see cref="IHost"/> with Serilog,
/// configuration (appsettings.json + Local + env vars), and the Marathon UI
/// service collection. Composes Application + Infrastructure modules
/// optionally — those module entry points may not yet exist while parallel
/// Phase 2/3/4 work merges.
/// </summary>
public partial class App : System.Windows.Application
{
public IHost? Host { get; private set; }
/// <summary>
/// Absolute path to the Local override settings file. Resolved from the
/// host's content root (the directory containing <c>appsettings.json</c>).
/// </summary>
public static string SettingsLocalFileName => "appsettings.Local.json";
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var contentRoot = AppContext.BaseDirectory;
var localSettingsPath = Path.Combine(contentRoot, SettingsLocalFileName);
var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder();
builder.Environment.ContentRootPath = contentRoot;
builder.Configuration
.SetBasePath(contentRoot)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddJsonFile(SettingsLocalFileName, optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "MARATHON_");
// Serilog — structured rolling-file + console.
// Minimum level honours the "Serilog:MinimumLevel:Default" key when
// present in configuration; otherwise defaults to Information.
var logsDir = Path.Combine(contentRoot, "logs");
Directory.CreateDirectory(logsDir);
var minimumLevel = ParseMinimumLevel(builder.Configuration["Serilog:MinimumLevel:Default"]);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Is(minimumLevel)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File(
path: Path.Combine(logsDir, "marathon-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
shared: true)
.CreateLogger();
builder.Services.AddSerilog();
// Marathon.UI services (Mud, localization, options, theme/locale state, settings writer).
builder.Services.AddMarathonUi(builder.Configuration, localSettingsPath);
// Blazor WebView root services.
builder.Services.AddWpfBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
#endif
// Compose Application + Infrastructure modules if they exist. Parallel
// Phase 2/3/4 work may still be merging these; we degrade gracefully.
TryAddApplicationAndInfrastructure(builder.Services, builder.Configuration);
// MainWindow needs the IServiceProvider for BlazorWebView.Services binding.
builder.Services.AddSingleton<MainWindow>();
Host = builder.Build();
Host.Start();
// Apply default culture from configuration before any UI renders.
var localeOptions = Host.Services.GetRequiredService<IOptions<LocalizationOptions>>().Value;
var locale = Host.Services.GetRequiredService<LocaleState>();
try
{
locale.Set(localeOptions.DefaultCulture);
}
catch (CultureNotFoundException)
{
locale.Set(LocaleState.Russian);
}
var window = Host.Services.GetRequiredService<MainWindow>();
window.Show();
}
private static Serilog.Events.LogEventLevel ParseMinimumLevel(string? raw) =>
Enum.TryParse<Serilog.Events.LogEventLevel>(raw, ignoreCase: true, out var level)
? level
: Serilog.Events.LogEventLevel.Information;
/// <summary>
/// Best-effort wiring of the Application + Infrastructure DI modules.
/// TODO(phase-4): the orchestrator will land a single
/// <c>AddMarathonInfrastructure(config)</c> entry point. Until then we use
/// reflection to call whichever extension methods exist so partial merges
/// don't break compilation of this host.
/// </summary>
private static void TryAddApplicationAndInfrastructure(IServiceCollection services, IConfiguration configuration)
{
TryInvokeExtension(services, configuration, "Marathon.Application.DependencyInjection", "AddMarathonApplication");
TryInvokeExtension(services, configuration, "Marathon.Infrastructure.DependencyInjection", "AddMarathonInfrastructure");
TryInvokeExtension(services, configuration, "Marathon.Infrastructure.Persistence.PersistenceServiceCollectionExtensions", "AddMarathonPersistence");
TryInvokeExtension(services, configuration, "Marathon.Infrastructure.Scraping.ScrapingServiceCollectionExtensions", "AddMarathonScraping");
}
private static void TryInvokeExtension(IServiceCollection services, IConfiguration configuration, string typeName, string methodName)
{
try
{
// Probe across all loaded assemblies — project refs cause them to load on startup.
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
var type = asm.GetType(typeName, throwOnError: false, ignoreCase: false);
if (type is null)
{
continue;
}
var method = type.GetMethod(methodName, new[] { typeof(IServiceCollection), typeof(IConfiguration) });
if (method is null)
{
continue;
}
method.Invoke(null, new object[] { services, configuration });
return;
}
}
catch (Exception ex)
{
Log.Warning(ex, "Optional module {Type}.{Method} not wired", typeName, methodName);
}
}
protected override void OnExit(ExitEventArgs e)
{
try
{
Host?.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Log.Error(ex, "Host shutdown failed");
}
finally
{
Host?.Dispose();
Log.CloseAndFlush();
base.OnExit(e);
}
}
}
+17 -7
View File
@@ -1,12 +1,22 @@
<Window x:Class="Marathon.Hosts.WpfBlazor.MainWindow"
<Window x:Class="Marathon.Hosts.WpfBlazor.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Marathon.Hosts.WpfBlazor"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
xmlns:wv="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
xmlns:ui="clr-namespace:Marathon.UI;assembly=Marathon.UI"
xmlns:b="clr-namespace:Microsoft.AspNetCore.Components.Web;assembly=Microsoft.AspNetCore.Components.Web"
Title="Marathon Odds Lab"
Height="900"
Width="1440"
MinHeight="640"
MinWidth="960"
Background="#0c0a09"
WindowStartupLocation="CenterScreen">
<Grid>
<wv:BlazorWebView x:Name="BlazorWebView"
HostPage="wwwroot/index.html">
<wv:BlazorWebView.RootComponents>
<wv:RootComponent Selector="#app" ComponentType="{x:Type ui:App}" />
</wv:BlazorWebView.RootComponents>
</wv:BlazorWebView>
</Grid>
</Window>
@@ -1,23 +1,18 @@
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace Marathon.Hosts.WpfBlazor;
/// <summary>
/// Interaction logic for MainWindow.xaml
/// Hosts the BlazorWebView that renders <see cref="Marathon.UI.App"/>.
/// All UI lives in the Razor Class Library — this window is intentionally
/// thin so a future ASP.NET Core Blazor Server host can swap in trivially.
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
public MainWindow(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
InitializeComponent();
BlazorWebView.Services = services;
}
}
}
@@ -1,10 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Marathon.UI\Marathon.UI.csproj" />
<ProjectReference Include="..\Marathon.Infrastructure\Marathon.Infrastructure.csproj" />
<ProjectReference Include="..\Marathon.Application\Marathon.Application.csproj" />
</ItemGroup>
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<OutputType>WinExe</OutputType>
@@ -12,6 +6,42 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>false</UseWindowsForms>
<RootNamespace>Marathon.Hosts.WpfBlazor</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Wpf" />
<PackageReference Include="MudBlazor" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Localization" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Hosting" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Serilog.Sinks.Console" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Marathon.UI\Marathon.UI.csproj" />
<ProjectReference Include="..\Marathon.Application\Marathon.Application.csproj" />
<ProjectReference Include="..\Marathon.Infrastructure\Marathon.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<DependentUpon>appsettings.json</DependentUpon>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Information",
"Microsoft.AspNetCore": "Information",
"System": "Information"
}
}
}
}
@@ -0,0 +1,50 @@
{
"Scraping": {
"PollingIntervalSeconds": 30,
"MaxConcurrentRequests": 4,
"UserAgents": [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
],
"RetryPolicy": {
"MaxAttempts": 3,
"BaseDelayMs": 500
},
"RateLimit": {
"RequestsPerSecond": 1
},
"UsePlaywright": false,
"BaseUrl": "https://www.marathonbet.by",
"RequestTimeoutSeconds": 30
},
"Workers": {
"UpcomingScheduleCron": "0 */5 * * * *",
"LivePollerEnabled": true,
"UpcomingPollerEnabled": true
},
"Storage": {
"DatabasePath": "./data/marathon.db",
"ExportDirectory": "./exports",
"SnapshotRetentionDays": 90
},
"Anomaly": {
"SuspensionGapSeconds": 60,
"OddsFlipThreshold": 0.30,
"MinSnapshotCount": 3,
"DetectionIntervalSeconds": 60
},
"Localization": {
"DefaultCulture": "ru-RU"
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
}
}
}