From a627c360c3b5bf20e2084fafa5516377f2546cef Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 9 May 2026 15:11:13 +0300 Subject: [PATCH] fix(ui): invariant decimals + LocaleState test isolation + drawer offset + bundled Plotly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-cutting polish that surfaced while Phase 8 was being implemented: * Invariant-culture formatting on every decimal ToString("0.00" / "0.##") (OddsCell, OddsTimeline, SeverityBadge, AnomalyEvidence, Pages/Events/Detail) — previously the comma/dot decimal separator switched with the locale and broke tabular alignment + tests. * LocaleState constructor no longer mutates process-wide ambient culture; apply only happens through Set(...). Stops parallel bUnit test runs leaking ru-RU into each other's threads. * MainLayout: drawer-open state now offsets main content / footer / appbar by the drawer width (248px) so the sidebar no longer overlaps content. Mobile breakpoint (≤720px) keeps the original full-width layout. * wwwroot/index.html (Marathon.UI RCL): switched from Plotly CDN to the bundled "_content/Plotly.Blazor/plotly-2.35.3.min.js" — works offline and matches the Plotly.Blazor 5.4.1 version pin. * Marathon.Hosts.WpfBlazor/wwwroot/index.html: host-level page that BlazorWebView's HostPage attribute resolves to. Same Plotly bundle, no autostart="false" (BlazorWebView auto-starts). --- .../wwwroot/index.html | 48 +++++++++++++++++++ .../Components/AnomalyEvidence.razor | 2 +- src/Marathon.UI/Components/OddsCell.razor | 4 +- src/Marathon.UI/Components/OddsTimeline.razor | 3 +- .../Components/SeverityBadge.razor | 2 +- src/Marathon.UI/MainLayout.razor | 19 ++++++++ src/Marathon.UI/Pages/Events/Detail.razor | 8 ++-- src/Marathon.UI/Services/LocaleState.cs | 30 ++++++++++-- src/Marathon.UI/wwwroot/index.html | 4 +- 9 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 src/Marathon.Hosts.WpfBlazor/wwwroot/index.html diff --git a/src/Marathon.Hosts.WpfBlazor/wwwroot/index.html b/src/Marathon.Hosts.WpfBlazor/wwwroot/index.html new file mode 100644 index 0000000..162d2ce --- /dev/null +++ b/src/Marathon.Hosts.WpfBlazor/wwwroot/index.html @@ -0,0 +1,48 @@ + + + + + + Marathon — Odds Lab + + + + + + + + + + + + + + +
+
+ Booting +
Marathon Odds Lab
+
+
+ + + + + + + + + + + diff --git a/src/Marathon.UI/Components/AnomalyEvidence.razor b/src/Marathon.UI/Components/AnomalyEvidence.razor index f06cc3f..e72d16c 100644 --- a/src/Marathon.UI/Components/AnomalyEvidence.razor +++ b/src/Marathon.UI/Components/AnomalyEvidence.razor @@ -217,7 +217,7 @@ builder.OpenElement(12, "span"); builder.AddAttribute(13, "class", "m-evidence__rate"); - builder.AddContent(14, rate is { } r ? r.ToString("0.00") : "—"); + builder.AddContent(14, rate is { } r ? r.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) : "—"); builder.CloseElement(); builder.CloseElement(); diff --git a/src/Marathon.UI/Components/OddsCell.razor b/src/Marathon.UI/Components/OddsCell.razor index 74561c0..9846531 100644 --- a/src/Marathon.UI/Components/OddsCell.razor +++ b/src/Marathon.UI/Components/OddsCell.razor @@ -87,7 +87,9 @@ [Parameter] public string AriaPrefix { get; set; } = "Odds"; [Parameter] public string EmptyPlaceholder { get; set; } = "—"; - private string Formatted => Rate is { } r ? r.ToString("0.00") : EmptyPlaceholder; + private string Formatted => Rate is { } r + ? r.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) + : EmptyPlaceholder; private Trend Direction { diff --git a/src/Marathon.UI/Components/OddsTimeline.razor b/src/Marathon.UI/Components/OddsTimeline.razor index 988df3d..2d41aa5 100644 --- a/src/Marathon.UI/Components/OddsTimeline.razor +++ b/src/Marathon.UI/Components/OddsTimeline.razor @@ -238,5 +238,6 @@ }, }; - private static string FormatRate(decimal? r) => r is { } v ? v.ToString("0.00") : "—"; + private static string FormatRate(decimal? r) => + r is { } v ? v.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) : "—"; } diff --git a/src/Marathon.UI/Components/SeverityBadge.razor b/src/Marathon.UI/Components/SeverityBadge.razor index 8a98fa6..73ab2ac 100644 --- a/src/Marathon.UI/Components/SeverityBadge.razor +++ b/src/Marathon.UI/Components/SeverityBadge.razor @@ -19,7 +19,7 @@ @Label @if (ShowScore && Score is { } s) { - + } diff --git a/src/Marathon.UI/MainLayout.razor b/src/Marathon.UI/MainLayout.razor index 4225975..2c575f4 100644 --- a/src/Marathon.UI/MainLayout.razor +++ b/src/Marathon.UI/MainLayout.razor @@ -83,6 +83,25 @@ min-height: 0; } + /* MudDrawer is positioned fixed/absolute by Mud's CSS — push main content + right by the drawer's width (248px) so the sidebar doesn't overlap. + Same shift on the appbar tools row so the brand isn't covered. */ + .m-app-frame.is-drawer-open .m-main, + .m-app-frame.is-drawer-open .m-footer { + padding-left: 248px; + transition: padding-left 200ms ease; + } + .m-app-frame.is-drawer-open .m-appbar { + padding-left: calc(248px + clamp(var(--m-space-3), 2vw, var(--m-space-5))); + transition: padding-left 200ms ease; + } + @@media (max-width: 720px) { + /* On narrow viewports the drawer becomes a temporary overlay anyway. */ + .m-app-frame.is-drawer-open .m-main, + .m-app-frame.is-drawer-open .m-footer, + .m-app-frame.is-drawer-open .m-appbar { padding-left: 0; } + } + .m-footer { display: flex; align-items: center; diff --git a/src/Marathon.UI/Pages/Events/Detail.razor b/src/Marathon.UI/Pages/Events/Detail.razor index 292e9aa..4f25fa6 100644 --- a/src/Marathon.UI/Pages/Events/Detail.razor +++ b/src/Marathon.UI/Pages/Events/Detail.razor @@ -105,7 +105,7 @@ @BetTypeLabel(row.Type) @SideLabel(row.Side) @FormatThreshold(row.Threshold) - @row.Rate.ToString("0.00") + @row.Rate.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) } @@ -334,6 +334,8 @@ _ => s.ToString(), }; - private static string FormatRate(decimal? r) => r is { } v ? v.ToString("0.00") : "—"; - private static string FormatThreshold(decimal? v) => v is { } x ? x.ToString("0.##") : "—"; + private static string FormatRate(decimal? r) => + r is { } v ? v.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) : "—"; + private static string FormatThreshold(decimal? v) => + v is { } x ? x.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture) : "—"; } diff --git a/src/Marathon.UI/Services/LocaleState.cs b/src/Marathon.UI/Services/LocaleState.cs index 12c4fcd..23a2111 100644 --- a/src/Marathon.UI/Services/LocaleState.cs +++ b/src/Marathon.UI/Services/LocaleState.cs @@ -15,23 +15,35 @@ public sealed class LocaleState public static readonly IReadOnlyList Supported = new[] { Russian, English }; - private CultureInfo _culture = CultureInfo.GetCultureInfo(Russian); + private CultureInfo _culture; + + public LocaleState() + { + // Initialise the field only — do NOT mutate process-wide ambient culture + // here. Production startup (App.xaml.cs) always calls Set(...) right after + // resolving this service, which applies ambient culture explicitly. Doing + // it here as well leaks ru-RU across parallel test runs whenever any + // bUnit test class instantiates MarathonTestContext. + _culture = CultureInfo.GetCultureInfo(Russian); + } public CultureInfo Culture { get => _culture; private set { + // Always re-apply ambient cultures even when the value matches — + // App.xaml.cs's startup call uses the same default the constructor + // initialized to, so an equality short-circuit here would leave the + // actual thread CurrentCulture stuck on the system locale. + ApplyAmbientCulture(value); + if (string.Equals(_culture.Name, value.Name, StringComparison.OrdinalIgnoreCase)) { return; } _culture = value; - CultureInfo.DefaultThreadCurrentCulture = value; - CultureInfo.DefaultThreadCurrentUICulture = value; - CultureInfo.CurrentCulture = value; - CultureInfo.CurrentUICulture = value; OnChange?.Invoke(); } } @@ -47,4 +59,12 @@ public sealed class LocaleState Culture = CultureInfo.GetCultureInfo(cultureName); } + + private static void ApplyAmbientCulture(CultureInfo c) + { + CultureInfo.DefaultThreadCurrentCulture = c; + CultureInfo.DefaultThreadCurrentUICulture = c; + CultureInfo.CurrentCulture = c; + CultureInfo.CurrentUICulture = c; + } } diff --git a/src/Marathon.UI/wwwroot/index.html b/src/Marathon.UI/wwwroot/index.html index fd229df..ebc4eb2 100644 --- a/src/Marathon.UI/wwwroot/index.html +++ b/src/Marathon.UI/wwwroot/index.html @@ -38,8 +38,8 @@ × - - + +