fix(initial-implementation): close P2/P3/P5 review blockers — 185/185 tests green

Combined-batch reviewer flagged three real blockers + two test-infra
issues across the parallel P2/P3/P5 batch. All resolved:

PHASE 3 — DateTimeOffset UTC-kind constructor (3 sites)
  EventListingParserBase.cs:39, EventOddsParser.cs:72, ResultsParser.cs:104
  Replaced `new DateTimeOffset(DateTimeOffset.UtcNow.UtcDateTime, MoscowOffset)`
  (throws ArgumentException because UtcDateTime has Kind=Utc) with
  `DateTimeOffset.UtcNow.ToOffset(MoscowOffset)`.

PHASE 2 — EF string.Compare not translatable (3 sites)
  EventRepository.cs:34, SnapshotRepository.cs:46, ExcelExporter.cs:35
  Replaced `string.Compare(col, str, StringComparison.Ordinal)` with
  `col.CompareTo(str)` so EF Core's SQLite provider can translate the
  expression. Semantics unchanged (SQLite default collation = BINARY = ordinal).

PHASE 3 — ServerTimeProvider regex misses JSON-quoted key
  Regex `serverTime\s*:\s*"..."` only matched bare-key form. Updated to
  `"?serverTime"?\s*:\s*"..."` so the JSON-quoted form (the actual
  marathonbet.by production format) is matched.

PHASE 3 — fixture: orphan <td> elements stripped by HTML5 parser
  tests/.../Fixtures/marathonbet/event-football-sample.html — wrapped
  the <td> blocks in a proper <table><tbody><tr> hierarchy so AngleSharp
  preserves them and `td.Closest("td")` succeeds in the parser.

PHASE 2 — InMemoryDbFixture shared state across parallel tests
  All fixture instances used `Data Source=marathon_tests` causing xUnit's
  parallel-within-class runs to contaminate each other's data. Each fixture
  now uses a Guid-suffixed unique data source name.

PLAN.md — P2/P3/P5 rows updated to  Done with batch commit reference.

Test status:
  Domain.Tests:         96/96 
  Application.Tests:     1/1  
  Infrastructure.Tests: 77/77 
  UI.Tests:             11/11 
  TOTAL:               185/185 
Build: 0 warnings, 0 errors.

Deferred to later phases (per reviewer 🟡 / 🔵 notes):
- SnapshotRepository.GetAsync(Guid) uses lossy GetHashCode workaround;
  Phase 4 to fix or remove from interface.
- Excel Sport name column writes string.Empty (need lookup join in Phase 6).
- PeriodScopeMapper football n>2 falls through to "Quarter" token;
  guarded by MaxPeriods today, but defensive cleanup at Phase 9.
- Settings.razor duplicate m-rise-5 class on Localization section.
This commit is contained in:
2026-05-05 12:09:44 +03:00
parent 686550d697
commit c4d87b59d6
10 changed files with 79 additions and 58 deletions
@@ -32,8 +32,8 @@ internal sealed class ExcelExporter : IExcelExporter
var snapshotEntities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
.Include(s => s.Event)
.Where(s => string.Compare(s.CapturedAt, fromStr, StringComparison.Ordinal) >= 0
&& string.Compare(s.CapturedAt, toStr, StringComparison.Ordinal) <= 0)
.Where(s => s.CapturedAt.CompareTo(fromStr) >= 0
&& s.CapturedAt.CompareTo(toStr) <= 0)
.ToListAsync(ct);
// Convert to domain objects for processing
@@ -30,9 +30,12 @@ internal sealed class EventRepository : IEventRepository
var fromStr = range.From.ToString("O");
var toStr = range.To.ToString("O");
// EF Core SQLite cannot translate string.Compare(...) with StringComparison; it can
// translate the relational operators on string columns (which use BINARY/ordinal
// collation by default in SQLite — correct for ISO 8601).
var entities = await _db.Events.AsNoTracking()
.Where(e => string.Compare(e.ScheduledAt, fromStr, StringComparison.Ordinal) >= 0
&& string.Compare(e.ScheduledAt, toStr, StringComparison.Ordinal) <= 0)
.Where(e => e.ScheduledAt.CompareTo(fromStr) >= 0
&& e.ScheduledAt.CompareTo(toStr) <= 0)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
@@ -43,8 +43,8 @@ internal sealed class SnapshotRepository : ISnapshotRepository
var entities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
.Where(s => s.EventCode == eventId.Value
&& string.Compare(s.CapturedAt, fromStr, StringComparison.Ordinal) >= 0
&& string.Compare(s.CapturedAt, toStr, StringComparison.Ordinal) <= 0)
&& s.CapturedAt.CompareTo(fromStr) >= 0
&& s.CapturedAt.CompareTo(toStr) <= 0)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
@@ -36,7 +36,7 @@ public abstract class EventListingParserBase
CancellationToken ct)
{
var serverTime = ServerTimeProvider.ExtractServerTime(html)
?? new DateTimeOffset(DateTimeOffset.UtcNow.UtcDateTime, MoscowOffset);
?? DateTimeOffset.UtcNow.ToOffset(MoscowOffset);
var config = AngleSharpConfig.Default;
using var context = BrowsingContext.New(config);
@@ -69,7 +69,7 @@ public sealed partial class EventOddsParser : IEventOddsParser
ArgumentNullException.ThrowIfNull(html);
var capturedAt = _serverTime.ExtractServerTime(html)
?? new DateTimeOffset(DateTimeOffset.UtcNow.UtcDateTime, MoscowOffset);
?? DateTimeOffset.UtcNow.ToOffset(MoscowOffset);
var config = AngleSharpConfig.Default;
using var context = BrowsingContext.New(config);
@@ -101,7 +101,7 @@ public sealed partial class ResultsParser : IResultsParser
if (string.IsNullOrWhiteSpace(eventIdRaw))
return null;
var completedAt = new DateTimeOffset(DateTimeOffset.UtcNow.UtcDateTime, MoscowOffset);
var completedAt = DateTimeOffset.UtcNow.ToOffset(MoscowOffset);
return new EventResult(
new DomainEventId(eventIdRaw),
@@ -9,9 +9,11 @@ namespace Marathon.Infrastructure.Scraping.Parsers;
/// </summary>
public sealed partial class ServerTimeProvider : IServerTimeProvider
{
// Matches: serverTime:"YYYY,MM,DD,HH,mm,ss"
// Matches both forms observed on marathonbet.by:
// serverTime:"YYYY,MM,DD,HH,mm,ss" (bare key)
// "serverTime":"YYYY,MM,DD,HH,mm,ss" (JSON-quoted key — actual prod format)
[GeneratedRegex(
@"serverTime\s*:\s*""(\d{4}),(\d{1,2}),(\d{1,2}),(\d{1,2}),(\d{1,2}),(\d{1,2})""",
@"""?serverTime""?\s*:\s*""(\d{4}),(\d{1,2}),(\d{1,2}),(\d{1,2}),(\d{1,2}),(\d{1,2})""",
RegexOptions.CultureInvariant)]
private static partial Regex ServerTimeRegex();