From b67030ae7f02ae39fd3e28612c125de57c4063c3 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 29 May 2026 01:25:25 +0300 Subject: [PATCH] test+chore: real-SQLite query coverage, batch detect writes, finish date centralization Review follow-ups: - (HIGH) Add real-SQLite round-trip tests for the new query methods so the load-bearing lexical O-format date ordering is verified, not just mocked: Anomaly ListByDateRange/CountSince, Snapshot CountSince/ListByEvents grouping, Event Query/GetMany. - (MED) DetectAnomaliesUseCase: one SaveChanges per event instead of per anomaly. - (LOW) Route PlacedBetRepository + ExcelExporter date bounds through SqliteDateText. - (LOW) Backtest: reject a one-sided date range (was silently ignored). - (LOW) Refresh stale comments after the detector fan-out. --- .../UseCases/DetectAnomaliesUseCase.cs | 12 ++- .../Export/ExcelExporter.cs | 7 +- .../Repositories/PlacedBetRepository.cs | 8 +- .../Services/BacktestViewModels.cs | 4 + .../Persistence/RoundTripTests.cs | 87 +++++++++++++++++++ 5 files changed, 107 insertions(+), 11 deletions(-) diff --git a/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs b/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs index 6450eff..3a8d74f 100644 --- a/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs +++ b/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs @@ -14,7 +14,7 @@ namespace Marathon.Application.UseCases; /// Loads all tracked events. /// For each event, fetches its last-24-hour live snapshots. /// Runs over the snapshot timeline. -/// Persists any new anomalies that have not already been stored (dedup by EventId + DetectedAt minute-window). +/// Persists any new anomalies that have not already been stored (dedup by EventId + Kind + DetectedAt minute-window). /// /// /// @@ -138,8 +138,8 @@ public sealed class DetectAnomaliesUseCase List existingForEvent, CancellationToken ct) { - // Fan out over every detector kind; dedup below keys on EventId + Kind so the - // flip and steam signals for one event persist independently. + // Fan out over every detector; dedup below keys on EventId + Kind so the flip, + // steam, and freeze signals for one event persist independently. var detected = detectors .SelectMany(d => d.Detect(ev.Id, snapshots)) .ToList(); @@ -154,11 +154,15 @@ public sealed class DetectAnomaliesUseCase continue; await _anomalyRepo.AddAsync(anomaly, ct); - await _anomalyRepo.SaveChangesAsync(ct); existingForEvent.Add(anomaly); // Keep local list in sync so the same cycle doesn't re-add. persisted++; } + // One write per event rather than per anomaly — with three detectors an event + // can yield several new anomalies in a single cycle. + if (persisted > 0) + await _anomalyRepo.SaveChangesAsync(ct); + return persisted; } diff --git a/src/Marathon.Infrastructure/Export/ExcelExporter.cs b/src/Marathon.Infrastructure/Export/ExcelExporter.cs index 39b5ef5..af114a3 100644 --- a/src/Marathon.Infrastructure/Export/ExcelExporter.cs +++ b/src/Marathon.Infrastructure/Export/ExcelExporter.cs @@ -25,9 +25,10 @@ internal sealed class ExcelExporter : IExcelExporter string outputPath, CancellationToken ct = default) { - // Load all snapshots in the date range with their bets eagerly - var fromStr = range.From.ToString("O"); - var toStr = range.To.ToString("O"); + // Load all snapshots in the date range with their bets eagerly. Bounds use the + // shared SqliteDateText encoding so they match the persisted CapturedAt keys. + var fromStr = SqliteDateText.Key(range.From); + var toStr = SqliteDateText.Key(range.To); var snapshotEntities = await _db.Snapshots.AsNoTracking() .Include(s => s.Bets) diff --git a/src/Marathon.Infrastructure/Persistence/Repositories/PlacedBetRepository.cs b/src/Marathon.Infrastructure/Persistence/Repositories/PlacedBetRepository.cs index d81491d..7dfc440 100644 --- a/src/Marathon.Infrastructure/Persistence/Repositories/PlacedBetRepository.cs +++ b/src/Marathon.Infrastructure/Persistence/Repositories/PlacedBetRepository.cs @@ -40,10 +40,10 @@ internal sealed class PlacedBetRepository : IPlacedBetRepository public async Task> ListByDateRangeAsync(DateRange range, CancellationToken ct = default) { - // PlacedAt is stored as ISO 8601 TEXT — same lexical-equals-chronological ordering - // trick used in EventRepository.ListByDateRangeAsync. - var fromStr = range.From.ToString("O"); - var toStr = range.To.ToString("O"); + // PlacedAt is stored via SqliteDateText (O-format TEXT) — same lexical-equals- + // chronological ordering used across the repositories. + var fromStr = SqliteDateText.Key(range.From); + var toStr = SqliteDateText.Key(range.To); var entities = await _db.PlacedBets.AsNoTracking() .Where(b => b.PlacedAt.CompareTo(fromStr) >= 0 diff --git a/src/Marathon.UI/Services/BacktestViewModels.cs b/src/Marathon.UI/Services/BacktestViewModels.cs index 2f35cc7..5287c19 100644 --- a/src/Marathon.UI/Services/BacktestViewModels.cs +++ b/src/Marathon.UI/Services/BacktestViewModels.cs @@ -33,6 +33,10 @@ public sealed class BacktestForm { if (StartingBankroll <= 0m) { error = "Bankroll must be positive."; return false; } if (MinScore is < 0m or > 1m) { error = "Min score must be in [0, 1]."; return false; } + // A one-sided range would be silently ignored (ToDateRange needs both bounds), so + // require both-or-neither and give the user explicit feedback. + if (From.HasValue != To.HasValue) + { error = "Set both From and To dates, or leave both empty."; return false; } if (From is { } f && To is { } t && f.Date > t.Date) { error = "From date must be on or before To date."; return false; } switch (StakeRule) diff --git a/tests/Marathon.Infrastructure.Tests/Persistence/RoundTripTests.cs b/tests/Marathon.Infrastructure.Tests/Persistence/RoundTripTests.cs index f0e82c9..bf2d349 100644 --- a/tests/Marathon.Infrastructure.Tests/Persistence/RoundTripTests.cs +++ b/tests/Marathon.Infrastructure.Tests/Persistence/RoundTripTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Marathon.Application.Storage; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; @@ -279,6 +280,92 @@ public sealed class RoundTripTests : IDisposable exception.Should().BeNull("PRAGMA journal_mode=WAL should execute without error"); } + // ── New query methods — verified against real SQLite (lexical O-format ordering) ── + + [Fact] + public async Task Anomaly_DateRangeAndCountSince_FilterByDetectedAt() + { + DateTimeOffset At(int day) => new(2026, 5, day, 12, 0, 0, MoscowOffset); + + await _eventRepo.AddAsync(BuildEvent("D100")); + await _eventRepo.SaveChangesAsync(); + + async Task AddAnomaly(DateTimeOffset at, decimal score) => + await _anomalyRepo.AddAsync(new Anomaly( + Guid.NewGuid(), new EventId("D100"), at, AnomalyKind.SuspensionFlip, score, "{}")); + + await AddAnomaly(At(1), 0.50m); + await AddAnomaly(At(5), 0.60m); + await AddAnomaly(At(10), 0.70m); + await _anomalyRepo.SaveChangesAsync(); + _fixture.DbContext.ChangeTracker.Clear(); + + // Inclusive [day3..day7] → only the day-5 anomaly. + var inRange = await _anomalyRepo.ListByDateRangeAsync(At(3), At(7)); + inRange.Should().ContainSingle(); + inRange[0].Score.Should().Be(0.60m); + + // Open-ended returns newest-first. + var all = await _anomalyRepo.ListByDateRangeAsync(null, null); + all.Select(a => a.DetectedAt).Should().BeInDescendingOrder(); + + // CountSinceAsync is strictly-after → only day-10. + (await _anomalyRepo.CountSinceAsync(At(5))).Should().Be(1); + } + + [Fact] + public async Task Snapshot_CountSinceAndListByEvents_FilterAndGroup() + { + DateTimeOffset At(int day) => new(2026, 5, day, 12, 0, 0, MoscowOffset); + + await _eventRepo.AddAsync(BuildEvent("S100")); + await _eventRepo.AddAsync(BuildEvent("S200")); + await _eventRepo.SaveChangesAsync(); + + OddsSnapshot Snap(string id, DateTimeOffset at) => + new(new EventId(id), at, OddsSource.Live, + new List { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.90m)) }); + + await _snapshotRepo.AddAsync(Snap("S100", At(1))); + await _snapshotRepo.AddAsync(Snap("S100", At(5))); + await _snapshotRepo.AddAsync(Snap("S200", At(6))); + await _snapshotRepo.SaveChangesAsync(); + _fixture.DbContext.ChangeTracker.Clear(); + + // Inclusive >= day3 → day5 (S100) + day6 (S200). + (await _snapshotRepo.CountSinceAsync(At(3))).Should().Be(2); + + var byEvent = await _snapshotRepo.ListByEventsAsync( + new[] { new EventId("S100"), new EventId("S200") }, At(1).AddDays(-1), At(10)); + byEvent[new EventId("S100")].Should().HaveCount(2); + byEvent[new EventId("S200")].Should().HaveCount(1); + } + + [Fact] + public async Task Event_QueryAndGetMany_FilterAndBatch() + { + DateTimeOffset At(int day) => new(2026, 5, day, 12, 0, 0, MoscowOffset); + + await _eventRepo.AddAsync(BuildEvent("Q1", At(1))); // sport 11 + await _eventRepo.AddAsync(BuildEvent("Q2", At(5))); // sport 11, in range + await _eventRepo.AddAsync(new Event( + new EventId("Q3"), new SportCode(6), "England", "premier-league", "", At(5), "Home", "Away")); + await _eventRepo.SaveChangesAsync(); + _fixture.DbContext.ChangeTracker.Clear(); + + // QueryAsync: [day3..day7] + sport 11 → only Q2 (Q3 is sport 6). + var queried = await _eventRepo.QueryAsync( + new EventQuery(new DateRange(At(3), At(7)), SportCodes: new[] { 11 })); + queried.Should().ContainSingle(); + queried[0].Id.Value.Should().Be("Q2"); + + // GetManyAsync: batched; unknown id is simply absent. + var many = await _eventRepo.GetManyAsync( + new[] { new EventId("Q1"), new EventId("Q3"), new EventId("missing") }); + many.Should().HaveCount(2); + many.Keys.Select(k => k.Value).Should().BeEquivalentTo(new[] { "Q1", "Q3" }); + } + // ── Helpers ───────────────────────────────────────────────────────────── private static Event BuildEvent(string id, DateTimeOffset? scheduledAt = null) =>