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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user