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,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);
}
}
}