feat(phase-4): application layer + background workers — 202/202 tests green

Use cases (Marathon.Application/UseCases/):
- PullUpcomingEventsUseCase: scrape + persist new events + capture pre-match snapshots
- PullLiveOddsUseCase: refresh live snapshots for all stored events
- PullResultsUseCase: Phase 4 scaffold; delegates to ScrapeResultsAsync (Phase 3 no-op);
  Phase 8 will replace with watch-list polling
- ExportToExcelUseCase: resolves export dir from StorageOptions, delegates to IExcelExporter

ApplicationModule.AddMarathonApplication(IServiceCollection) — no IConfiguration needed.

Background workers (Marathon.Infrastructure/Workers/):
- UpcomingEventsPoller: Cronos 6-field cron schedule (default every 6 h)
- LiveOddsPoller: fixed interval (WorkerOptions.LivePollIntervalSeconds, default 30 s)
- ResultsWatchListPoller: scaffold, disabled by default (WorkerOptions.ResultsPollerEnabled=false)
All three: exception-swallowing, cancellation-aware, scoped DI via CreateAsyncScope().

InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration):
- Composes AddMarathonPersistence + AddMarathonScraping + WorkerOptions + 3 hosted services

App.xaml.cs: replace reflection-based TryAddApplicationAndInfrastructure with direct
AddMarathonApplication() + AddMarathonInfrastructure(config) calls.

Resolved Phase 3 TODO: bind Sports:Basketball:QuarterMode from config in ScrapingModule.

appsettings.json: add Workers.LivePollIntervalSeconds, ResultsPollIntervalSeconds,
ResultsPollerEnabled; add Sports.Basketball.QuarterMode.

Settings.razor + WorkerOptions (UI) + SharedResource.*.resx: surface new Workers fields.

Tests: +14 Application use-case tests, +3 Infrastructure worker tests (185 → 202 total).
This commit is contained in:
2026-05-05 12:28:15 +03:00
parent c4d87b59d6
commit 2acbaa5b77
31 changed files with 1719 additions and 94 deletions
+5 -47
View File
@@ -1,6 +1,8 @@
using System.Globalization;
using System.IO;
using System.Windows;
using Marathon.Application;
using Marathon.Infrastructure;
using Marathon.UI.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -73,9 +75,9 @@ public partial class App : System.Windows.Application
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);
// Application use cases + Infrastructure (persistence, scraping, workers).
builder.Services.AddMarathonApplication();
builder.Services.AddMarathonInfrastructure(builder.Configuration);
// MainWindow needs the IServiceProvider for BlazorWebView.Services binding.
builder.Services.AddSingleton<MainWindow>();
@@ -104,50 +106,6 @@ public partial class App : System.Windows.Application
? 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
+10 -2
View File
@@ -19,9 +19,17 @@
"RequestTimeoutSeconds": 30
},
"Workers": {
"UpcomingScheduleCron": "0 */5 * * * *",
"UpcomingScheduleCron": "0 0 */6 * * *",
"LivePollerEnabled": true,
"UpcomingPollerEnabled": true
"UpcomingPollerEnabled": true,
"LivePollIntervalSeconds": 30,
"ResultsPollIntervalSeconds": 300,
"ResultsPollerEnabled": false
},
"Sports": {
"Basketball": {
"QuarterMode": false
}
},
"Storage": {
"DatabasePath": "./data/marathon.db",