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:
@@ -64,10 +64,10 @@ parameter configurable.
|
|||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| Phase 0: Scraping spike | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ⏭️ N/A (research) | ✅ 070e34b |
|
| Phase 0: Scraping spike | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ⏭️ N/A (research) | ✅ 070e34b |
|
||||||
| Phase 1: Solution + Domain | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 96/96 Domain tests | ✅ 61114ea |
|
| Phase 1: Solution + Domain | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 96/96 Domain tests | ✅ 61114ea |
|
||||||
| Phase 2: Storage | backend | 🔨 Code done, not committed | ⬜ Pending | ⏭️ Big Bang (own code 0/0) | ⬜ WIP |
|
| Phase 2: Storage | backend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 77/77 Infra tests | ✅ batch (e4d8476…686550d…+) |
|
||||||
| Phase 3: Scraping | backend | 🔨 Code done, not committed | ⬜ Pending | ⏭️ Big Bang (own code 0/0) | ⬜ WIP |
|
| Phase 3: Scraping | backend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 77/77 Infra tests | ✅ batch (e4d8476…686550d…+) |
|
||||||
| Phase 4: Application + Workers | backend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
| Phase 4: Application + Workers | backend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
||||||
| Phase 5: Host + Theme + i18n | frontend | 🔨 ~95% (killed mid-final-verify) | ⬜ Pending | ⏭️ Big Bang | ⬜ WIP |
|
| Phase 5: Host + Theme + i18n | frontend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 11/11 UI tests | ✅ batch (e4d8476…686550d…+) |
|
||||||
| Phase 6: Event browsing UI | frontend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
| Phase 6: Event browsing UI | frontend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
||||||
| Phase 7: Anomaly detection | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
| Phase 7: Anomaly detection | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
||||||
| Phase 8: Results loader | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
| Phase 8: Results loader | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ internal sealed class ExcelExporter : IExcelExporter
|
|||||||
var snapshotEntities = await _db.Snapshots.AsNoTracking()
|
var snapshotEntities = await _db.Snapshots.AsNoTracking()
|
||||||
.Include(s => s.Bets)
|
.Include(s => s.Bets)
|
||||||
.Include(s => s.Event)
|
.Include(s => s.Event)
|
||||||
.Where(s => string.Compare(s.CapturedAt, fromStr, StringComparison.Ordinal) >= 0
|
.Where(s => s.CapturedAt.CompareTo(fromStr) >= 0
|
||||||
&& string.Compare(s.CapturedAt, toStr, StringComparison.Ordinal) <= 0)
|
&& s.CapturedAt.CompareTo(toStr) <= 0)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
// Convert to domain objects for processing
|
// Convert to domain objects for processing
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ internal sealed class EventRepository : IEventRepository
|
|||||||
var fromStr = range.From.ToString("O");
|
var fromStr = range.From.ToString("O");
|
||||||
var toStr = range.To.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()
|
var entities = await _db.Events.AsNoTracking()
|
||||||
.Where(e => string.Compare(e.ScheduledAt, fromStr, StringComparison.Ordinal) >= 0
|
.Where(e => e.ScheduledAt.CompareTo(fromStr) >= 0
|
||||||
&& string.Compare(e.ScheduledAt, toStr, StringComparison.Ordinal) <= 0)
|
&& e.ScheduledAt.CompareTo(toStr) <= 0)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ internal sealed class SnapshotRepository : ISnapshotRepository
|
|||||||
var entities = await _db.Snapshots.AsNoTracking()
|
var entities = await _db.Snapshots.AsNoTracking()
|
||||||
.Include(s => s.Bets)
|
.Include(s => s.Bets)
|
||||||
.Where(s => s.EventCode == eventId.Value
|
.Where(s => s.EventCode == eventId.Value
|
||||||
&& string.Compare(s.CapturedAt, fromStr, StringComparison.Ordinal) >= 0
|
&& s.CapturedAt.CompareTo(fromStr) >= 0
|
||||||
&& string.Compare(s.CapturedAt, toStr, StringComparison.Ordinal) <= 0)
|
&& s.CapturedAt.CompareTo(toStr) <= 0)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public abstract class EventListingParserBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var serverTime = ServerTimeProvider.ExtractServerTime(html)
|
var serverTime = ServerTimeProvider.ExtractServerTime(html)
|
||||||
?? new DateTimeOffset(DateTimeOffset.UtcNow.UtcDateTime, MoscowOffset);
|
?? DateTimeOffset.UtcNow.ToOffset(MoscowOffset);
|
||||||
|
|
||||||
var config = AngleSharpConfig.Default;
|
var config = AngleSharpConfig.Default;
|
||||||
using var context = BrowsingContext.New(config);
|
using var context = BrowsingContext.New(config);
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public sealed partial class EventOddsParser : IEventOddsParser
|
|||||||
ArgumentNullException.ThrowIfNull(html);
|
ArgumentNullException.ThrowIfNull(html);
|
||||||
|
|
||||||
var capturedAt = _serverTime.ExtractServerTime(html)
|
var capturedAt = _serverTime.ExtractServerTime(html)
|
||||||
?? new DateTimeOffset(DateTimeOffset.UtcNow.UtcDateTime, MoscowOffset);
|
?? DateTimeOffset.UtcNow.ToOffset(MoscowOffset);
|
||||||
|
|
||||||
var config = AngleSharpConfig.Default;
|
var config = AngleSharpConfig.Default;
|
||||||
using var context = BrowsingContext.New(config);
|
using var context = BrowsingContext.New(config);
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ public sealed partial class ResultsParser : IResultsParser
|
|||||||
if (string.IsNullOrWhiteSpace(eventIdRaw))
|
if (string.IsNullOrWhiteSpace(eventIdRaw))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var completedAt = new DateTimeOffset(DateTimeOffset.UtcNow.UtcDateTime, MoscowOffset);
|
var completedAt = DateTimeOffset.UtcNow.ToOffset(MoscowOffset);
|
||||||
|
|
||||||
return new EventResult(
|
return new EventResult(
|
||||||
new DomainEventId(eventIdRaw),
|
new DomainEventId(eventIdRaw),
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ namespace Marathon.Infrastructure.Scraping.Parsers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class ServerTimeProvider : IServerTimeProvider
|
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(
|
[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)]
|
RegexOptions.CultureInvariant)]
|
||||||
private static partial Regex ServerTimeRegex();
|
private static partial Regex ServerTimeRegex();
|
||||||
|
|
||||||
|
|||||||
+50
-40
@@ -29,46 +29,56 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Match Result (1x2) -->
|
<table>
|
||||||
<td data-market-type="RESULT">
|
<tbody>
|
||||||
<span data-selection-price="1.65" data-selection-key="26456117@Match_Result.1">1.65</span>
|
<tr>
|
||||||
</td>
|
<!-- Match Result (1x2) -->
|
||||||
<td data-market-type="RESULT">
|
<td data-market-type="RESULT">
|
||||||
<span data-selection-price="4.1" data-selection-key="26456117@Match_Result.draw">4.10</span>
|
<span data-selection-price="1.65" data-selection-key="26456117@Match_Result.1">1.65</span>
|
||||||
</td>
|
</td>
|
||||||
<td data-market-type="RESULT">
|
<td data-market-type="RESULT">
|
||||||
<span data-selection-price="5.7" data-selection-key="26456117@Match_Result.3">5.70</span>
|
<span data-selection-price="4.1" data-selection-key="26456117@Match_Result.draw">4.10</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td data-market-type="RESULT">
|
||||||
<!-- Handicap -->
|
<span data-selection-price="5.7" data-selection-key="26456117@Match_Result.3">5.70</span>
|
||||||
<td data-market-type="HANDICAP">
|
</td>
|
||||||
(-1.0)<br/>
|
</tr>
|
||||||
<span data-selection-price="2.04" data-selection-key="26456117@To_Win_Match_With_Handicap.HB_H">2.04</span>
|
<tr>
|
||||||
</td>
|
<!-- Handicap -->
|
||||||
<td data-market-type="HANDICAP">
|
<td data-market-type="HANDICAP">
|
||||||
(+1.0)<br/>
|
(-1.0)<br/>
|
||||||
<span data-selection-price="1.82" data-selection-key="26456117@To_Win_Match_With_Handicap.HB_A">1.82</span>
|
<span data-selection-price="2.04" data-selection-key="26456117@To_Win_Match_With_Handicap.HB_H">2.04</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td data-market-type="HANDICAP">
|
||||||
<!-- Total Goals -->
|
(+1.0)<br/>
|
||||||
<td data-market-type="TOTAL">
|
<span data-selection-price="1.82" data-selection-key="26456117@To_Win_Match_With_Handicap.HB_A">1.82</span>
|
||||||
(2.5)<br/>
|
</td>
|
||||||
<span data-selection-price="1.92" data-selection-key="26456117@Total_Goals.Under_2.5">1.92</span>
|
</tr>
|
||||||
</td>
|
<tr>
|
||||||
<td data-market-type="TOTAL">
|
<!-- Total Goals -->
|
||||||
(2.5)<br/>
|
<td data-market-type="TOTAL">
|
||||||
<span data-selection-price="1.92" data-selection-key="26456117@Total_Goals.Over_2.5">1.92</span>
|
(2.5)<br/>
|
||||||
</td>
|
<span data-selection-price="1.92" data-selection-key="26456117@Total_Goals.Under_2.5">1.92</span>
|
||||||
|
</td>
|
||||||
<!-- Period 1 (1st Half) Result -->
|
<td data-market-type="TOTAL">
|
||||||
<span data-selection-price="1.80" data-selection-key="26456117@Result_-_1st_Half.RN_H">1.80</span>
|
(2.5)<br/>
|
||||||
<span data-selection-price="3.60" data-selection-key="26456117@Result_-_1st_Half.RN_D">3.60</span>
|
<span data-selection-price="1.92" data-selection-key="26456117@Total_Goals.Over_2.5">1.92</span>
|
||||||
<span data-selection-price="4.20" data-selection-key="26456117@Result_-_1st_Half.RN_A">4.20</span>
|
</td>
|
||||||
|
</tr>
|
||||||
<!-- Period 2 (2nd Half) Result -->
|
<tr>
|
||||||
<span data-selection-price="2.10" data-selection-key="26456117@Result_-_2nd_Half.RN_H">2.10</span>
|
<!-- Period 1 (1st Half) Result -->
|
||||||
<span data-selection-price="2.80" data-selection-key="26456117@Result_-_2nd_Half.RN_D">2.80</span>
|
<td><span data-selection-price="1.80" data-selection-key="26456117@Result_-_1st_Half.RN_H">1.80</span></td>
|
||||||
<span data-selection-price="3.50" data-selection-key="26456117@Result_-_2nd_Half.RN_A">3.50</span>
|
<td><span data-selection-price="3.60" data-selection-key="26456117@Result_-_1st_Half.RN_D">3.60</span></td>
|
||||||
|
<td><span data-selection-price="4.20" data-selection-key="26456117@Result_-_1st_Half.RN_A">4.20</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<!-- Period 2 (2nd Half) Result -->
|
||||||
|
<td><span data-selection-price="2.10" data-selection-key="26456117@Result_-_2nd_Half.RN_H">2.10</span></td>
|
||||||
|
<td><span data-selection-price="2.80" data-selection-key="26456117@Result_-_2nd_Half.RN_D">2.80</span></td>
|
||||||
|
<td><span data-selection-price="3.50" data-selection-key="26456117@Result_-_2nd_Half.RN_A">3.50</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,13 +17,19 @@ public sealed class InMemoryDbFixture : IDisposable
|
|||||||
|
|
||||||
public InMemoryDbFixture()
|
public InMemoryDbFixture()
|
||||||
{
|
{
|
||||||
// Keep a single connection open so the in-memory DB is not dropped between
|
// Each fixture instance gets its own in-memory database so xUnit's per-test
|
||||||
// DbContext operations. Cache=Shared ensures the same DB is reused.
|
// isolation isn't violated when tests run in parallel within a class
|
||||||
_keepAliveConnection = new SqliteConnection("Data Source=marathon_tests;Mode=Memory;Cache=Shared");
|
// (the previous shared "marathon_tests" name caused state contamination).
|
||||||
|
var dbName = $"marathon_tests_{Guid.NewGuid():N}";
|
||||||
|
var connectionString = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
||||||
|
|
||||||
|
// Keep a single connection open so the named in-memory DB lives until the
|
||||||
|
// fixture is disposed.
|
||||||
|
_keepAliveConnection = new SqliteConnection(connectionString);
|
||||||
_keepAliveConnection.Open();
|
_keepAliveConnection.Open();
|
||||||
|
|
||||||
var options = new DbContextOptionsBuilder<MarathonDbContext>()
|
var options = new DbContextOptionsBuilder<MarathonDbContext>()
|
||||||
.UseSqlite("Data Source=marathon_tests;Mode=Memory;Cache=Shared")
|
.UseSqlite(connectionString)
|
||||||
.Options;
|
.Options;
|
||||||
|
|
||||||
DbContext = new MarathonDbContext(options);
|
DbContext = new MarathonDbContext(options);
|
||||||
|
|||||||
Reference in New Issue
Block a user