feat(ops): pipeline-health dashboard
- Add a /health ops page: snapshot freshness (last-capture-at, colour-coded), 24h vs total snapshots + anomalies, events tracked, sports covered, and the four worker on/off states. Nav entry under System; localized en+ru. - New IPipelineHealthService + ISnapshotRepository.GetLatestCapturedAtAsync (max CapturedAt via indexed ORDER BY/LIMIT 1), with a real-SQLite round-trip test.
This commit is contained in:
@@ -22,6 +22,12 @@ public interface ISnapshotRepository
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
|
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The most recent snapshot capture time across all events, or <c>null</c> when the
|
||||||
|
/// store is empty. Backs the pipeline-health freshness indicator.
|
||||||
|
/// </summary>
|
||||||
|
Task<DateTimeOffset?> GetLatestCapturedAtAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
||||||
EventId eventId,
|
EventId eventId,
|
||||||
DateTimeOffset from,
|
DateTimeOffset from,
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ internal sealed class SnapshotRepository : ISnapshotRepository
|
|||||||
.CountAsync(ct);
|
.CountAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<DateTimeOffset?> GetLatestCapturedAtAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// O-format TEXT sorts lexically == chronologically (see SqliteDateText), so the
|
||||||
|
// max CapturedAt is the most recent capture. ORDER BY + LIMIT 1 pushed to SQLite.
|
||||||
|
var latest = await _db.Snapshots.AsNoTracking()
|
||||||
|
.OrderByDescending(s => s.CapturedAt)
|
||||||
|
.Select(s => s.CapturedAt)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
return latest is null ? null : SqliteDateText.Parse(latest);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
public async Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
||||||
EventId eventId,
|
EventId eventId,
|
||||||
DateTimeOffset from,
|
DateTimeOffset from,
|
||||||
|
|||||||
@@ -57,6 +57,10 @@
|
|||||||
<MudIcon Icon="@Icons.Material.Outlined.FileDownload" Size="Size.Small" />
|
<MudIcon Icon="@Icons.Material.Outlined.FileDownload" Size="Size.Small" />
|
||||||
<span>@L["Nav.Export"]</span>
|
<span>@L["Nav.Export"]</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink class="m-nav__link" href="health">
|
||||||
|
<MudIcon Icon="@Icons.Material.Outlined.MonitorHeart" Size="Size.Small" />
|
||||||
|
<span>@L["Nav.Health"]</span>
|
||||||
|
</NavLink>
|
||||||
<NavLink class="m-nav__link" href="settings">
|
<NavLink class="m-nav__link" href="settings">
|
||||||
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" />
|
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" />
|
||||||
<span>@L["Nav.Settings"]</span>
|
<span>@L["Nav.Settings"]</span>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
@page "/health"
|
||||||
|
@inject IStringLocalizer<SharedResource> L
|
||||||
|
@inject IPipelineHealthService HealthService
|
||||||
|
@inject ILogger<Health> Logger
|
||||||
|
|
||||||
|
<PageTitle>@L["App.Title"] · @L["Nav.Health"]</PageTitle>
|
||||||
|
|
||||||
|
<section class="m-shell">
|
||||||
|
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
|
||||||
|
<span class="m-kicker">@L["Health.Kicker"]</span>
|
||||||
|
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Health.Title"]</h1>
|
||||||
|
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@L["Health.Lede"]</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<hr class="m-rule--double" />
|
||||||
|
|
||||||
|
<div class="m-rise m-rise-2" data-test="health-freshness"
|
||||||
|
style="display: inline-flex; align-items: center; gap: 10px; font-family: var(--m-font-mono); font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.12em;">
|
||||||
|
<span style="width: 10px; height: 10px; border-radius: 50%; background: @FreshnessColor;"></span>
|
||||||
|
<span style="color: var(--m-c-ink-soft);">@L["Health.LastCapture"]:</span>
|
||||||
|
<span style="color: @FreshnessColor;">@FreshnessText</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-grid--three m-rise m-rise-2" style="margin-top: var(--m-space-5);">
|
||||||
|
<StatCard Label="@L["Health.Stat.Snapshots"]"
|
||||||
|
Value="@_health.SnapshotsLast24h.ToString("N0")"
|
||||||
|
Delta="@string.Format(CultureInfo.CurrentCulture, L["Health.Total"].Value, _health.SnapshotsTotal)" />
|
||||||
|
<StatCard Label="@L["Health.Stat.Anomalies"]"
|
||||||
|
Value="@_health.AnomaliesLast24h.ToString("N0")"
|
||||||
|
Delta="@string.Format(CultureInfo.CurrentCulture, L["Health.Total"].Value, _health.AnomaliesTotal)"
|
||||||
|
Anomaly="true" />
|
||||||
|
<StatCard Label="@L["Health.Stat.Events"]" Value="@_health.EventsTracked.ToString("N0")" />
|
||||||
|
<StatCard Label="@L["Health.Stat.Sports"]" Value="@_health.SportsCovered.ToString()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="m-card m-card--accented m-rise m-rise-3" style="margin-top: var(--m-space-6);">
|
||||||
|
<span class="m-kicker">@L["Health.Workers"]</span>
|
||||||
|
<ol style="list-style: none; padding: 0; margin: var(--m-space-4) 0 0; display: grid; gap: var(--m-space-3);">
|
||||||
|
<PipelineStep Index="01" Label="@L["Health.Worker.Schedule"]" Status="@WorkerStatus(_health.UpcomingPollerEnabled)" />
|
||||||
|
<PipelineStep Index="02" Label="@L["Health.Worker.Live"]" Status="@WorkerStatus(_health.LivePollerEnabled)" />
|
||||||
|
<PipelineStep Index="03" Label="@L["Health.Worker.Detection"]" Status="@WorkerStatus(_health.AnomalyDetectionEnabled)" />
|
||||||
|
<PipelineStep Index="04" Label="@L["Health.Worker.Results"]" Status="@WorkerStatus(_health.ResultsPollerEnabled)" />
|
||||||
|
</ol>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
@if (!_health.HasData)
|
||||||
|
{
|
||||||
|
<p class="m-rise m-rise-3" data-test="health-empty"
|
||||||
|
style="margin-top: var(--m-space-5); color: var(--m-c-ink-soft);">
|
||||||
|
@L["Health.Empty"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private PipelineHealth _health = PipelineHealth.Empty;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_health = await HealthService.GetAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Health: failed to load pipeline health.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WorkerStatus(bool enabled) => enabled ? "ok" : "idle";
|
||||||
|
|
||||||
|
private double? MinutesSinceLastCapture =>
|
||||||
|
_health.LastSnapshotAt is { } at ? (MoscowTime.Now - at).TotalMinutes : null;
|
||||||
|
|
||||||
|
private string FreshnessText
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (MinutesSinceLastCapture is not { } mins)
|
||||||
|
return L["Health.LastCapture.Never"];
|
||||||
|
return string.Format(CultureInfo.CurrentCulture, L["Health.MinutesAgo"].Value, (int)Math.Max(0, mins));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Green when fresh (<15 min), amber when slowing (<60 min), signal-red when stale.
|
||||||
|
private string FreshnessColor => MinutesSinceLastCapture switch
|
||||||
|
{
|
||||||
|
null => "var(--m-c-ink-soft)",
|
||||||
|
< 15 => "var(--m-c-positive)",
|
||||||
|
< 60 => "var(--m-c-accent)",
|
||||||
|
_ => "var(--m-c-anomaly)",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
<data name="Nav.Results"><value>Results</value></data>
|
<data name="Nav.Results"><value>Results</value></data>
|
||||||
<data name="Nav.Settings"><value>Settings</value></data>
|
<data name="Nav.Settings"><value>Settings</value></data>
|
||||||
<data name="Nav.Export"><value>Export</value></data>
|
<data name="Nav.Export"><value>Export</value></data>
|
||||||
|
<data name="Nav.Health"><value>Health</value></data>
|
||||||
|
|
||||||
<data name="Home.Kicker"><value>Briefing</value></data>
|
<data name="Home.Kicker"><value>Briefing</value></data>
|
||||||
<data name="Home.Title"><value>Hunting odds-flip anomalies</value></data>
|
<data name="Home.Title"><value>Hunting odds-flip anomalies</value></data>
|
||||||
@@ -272,6 +273,23 @@
|
|||||||
<data name="Export.Hub.Lede"><value>Export captured odds snapshots to an Excel workbook for any date range — no need to open a specific event first.</value></data>
|
<data name="Export.Hub.Lede"><value>Export captured odds snapshots to an Excel workbook for any date range — no need to open a specific event first.</value></data>
|
||||||
<data name="Export.Hub.Action"><value>Configure export</value></data>
|
<data name="Export.Hub.Action"><value>Configure export</value></data>
|
||||||
<data name="Export.Hub.FilenameHint"><value>Saved as Marathon_<from>_to_<to>.xlsx in the configured export directory.</value></data>
|
<data name="Export.Hub.FilenameHint"><value>Saved as Marathon_<from>_to_<to>.xlsx in the configured export directory.</value></data>
|
||||||
|
<data name="Health.Kicker"><value>Operations</value></data>
|
||||||
|
<data name="Health.Title"><value>Pipeline health</value></data>
|
||||||
|
<data name="Health.Lede"><value>Capture freshness, recent volumes, and worker status at a glance.</value></data>
|
||||||
|
<data name="Health.LastCapture"><value>Last capture</value></data>
|
||||||
|
<data name="Health.LastCapture.Never"><value>no captures yet</value></data>
|
||||||
|
<data name="Health.MinutesAgo"><value>{0} min ago</value></data>
|
||||||
|
<data name="Health.Stat.Snapshots"><value>Snapshots (24h)</value></data>
|
||||||
|
<data name="Health.Stat.Anomalies"><value>Anomalies (24h)</value></data>
|
||||||
|
<data name="Health.Stat.Events"><value>Events tracked</value></data>
|
||||||
|
<data name="Health.Stat.Sports"><value>Sports covered</value></data>
|
||||||
|
<data name="Health.Total"><value>{0} total</value></data>
|
||||||
|
<data name="Health.Workers"><value>Workers</value></data>
|
||||||
|
<data name="Health.Worker.Schedule"><value>Schedule poller</value></data>
|
||||||
|
<data name="Health.Worker.Live"><value>Live poller</value></data>
|
||||||
|
<data name="Health.Worker.Detection"><value>Anomaly detection</value></data>
|
||||||
|
<data name="Health.Worker.Results"><value>Results poller</value></data>
|
||||||
|
<data name="Health.Empty"><value>No data captured yet — enable the pollers in Settings.</value></data>
|
||||||
<data name="Export.Error.MissingDates"><value>Pick a start and end date.</value></data>
|
<data name="Export.Error.MissingDates"><value>Pick a start and end date.</value></data>
|
||||||
<data name="Export.Error.InvalidRange"><value>End date must be on or after the start date.</value></data>
|
<data name="Export.Error.InvalidRange"><value>End date must be on or after the start date.</value></data>
|
||||||
<data name="Export.Error.Failed"><value>Export failed.</value></data>
|
<data name="Export.Error.Failed"><value>Export failed.</value></data>
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
<data name="Nav.Results"><value>Результаты</value></data>
|
<data name="Nav.Results"><value>Результаты</value></data>
|
||||||
<data name="Nav.Settings"><value>Настройки</value></data>
|
<data name="Nav.Settings"><value>Настройки</value></data>
|
||||||
<data name="Nav.Export"><value>Экспорт</value></data>
|
<data name="Nav.Export"><value>Экспорт</value></data>
|
||||||
|
<data name="Nav.Health"><value>Состояние</value></data>
|
||||||
|
|
||||||
<!-- Home / Dashboard -->
|
<!-- Home / Dashboard -->
|
||||||
<data name="Home.Kicker"><value>Сводка</value></data>
|
<data name="Home.Kicker"><value>Сводка</value></data>
|
||||||
@@ -285,6 +286,23 @@
|
|||||||
<data name="Export.Hub.Lede"><value>Экспорт собранных снимков коэффициентов в книгу Excel за любой диапазон дат — без необходимости открывать конкретное событие.</value></data>
|
<data name="Export.Hub.Lede"><value>Экспорт собранных снимков коэффициентов в книгу Excel за любой диапазон дат — без необходимости открывать конкретное событие.</value></data>
|
||||||
<data name="Export.Hub.Action"><value>Настроить экспорт</value></data>
|
<data name="Export.Hub.Action"><value>Настроить экспорт</value></data>
|
||||||
<data name="Export.Hub.FilenameHint"><value>Сохраняется как Marathon_<от>_to_<до>.xlsx в указанной папке экспорта.</value></data>
|
<data name="Export.Hub.FilenameHint"><value>Сохраняется как Marathon_<от>_to_<до>.xlsx в указанной папке экспорта.</value></data>
|
||||||
|
<data name="Health.Kicker"><value>Операции</value></data>
|
||||||
|
<data name="Health.Title"><value>Состояние конвейера</value></data>
|
||||||
|
<data name="Health.Lede"><value>Свежесть сбора, недавние объёмы и статус воркеров на одном экране.</value></data>
|
||||||
|
<data name="Health.LastCapture"><value>Последний снимок</value></data>
|
||||||
|
<data name="Health.LastCapture.Never"><value>снимков пока нет</value></data>
|
||||||
|
<data name="Health.MinutesAgo"><value>{0} мин назад</value></data>
|
||||||
|
<data name="Health.Stat.Snapshots"><value>Снимков (24ч)</value></data>
|
||||||
|
<data name="Health.Stat.Anomalies"><value>Аномалий (24ч)</value></data>
|
||||||
|
<data name="Health.Stat.Events"><value>Событий в работе</value></data>
|
||||||
|
<data name="Health.Stat.Sports"><value>Видов спорта</value></data>
|
||||||
|
<data name="Health.Total"><value>всего {0}</value></data>
|
||||||
|
<data name="Health.Workers"><value>Воркеры</value></data>
|
||||||
|
<data name="Health.Worker.Schedule"><value>Сбор расписания</value></data>
|
||||||
|
<data name="Health.Worker.Live"><value>Сбор лайва</value></data>
|
||||||
|
<data name="Health.Worker.Detection"><value>Детектор аномалий</value></data>
|
||||||
|
<data name="Health.Worker.Results"><value>Сбор результатов</value></data>
|
||||||
|
<data name="Health.Empty"><value>Данные ещё не собраны — включите сборщики в «Настройках».</value></data>
|
||||||
<data name="Export.Error.MissingDates"><value>Выберите даты начала и конца.</value></data>
|
<data name="Export.Error.MissingDates"><value>Выберите даты начала и конца.</value></data>
|
||||||
<data name="Export.Error.InvalidRange"><value>Дата конца должна быть не раньше даты начала.</value></data>
|
<data name="Export.Error.InvalidRange"><value>Дата конца должна быть не раньше даты начала.</value></data>
|
||||||
<data name="Export.Error.Failed"><value>Экспорт не удался.</value></data>
|
<data name="Export.Error.Failed"><value>Экспорт не удался.</value></data>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Marathon.UI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only operational health of the capture/detection pipeline, for the ops page:
|
||||||
|
/// data freshness, recent vs total volumes, and which workers are enabled.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPipelineHealthService
|
||||||
|
{
|
||||||
|
Task<PipelineHealth> GetAsync(CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Snapshot of pipeline health. <see cref="LastSnapshotAt"/> drives the freshness pill.</summary>
|
||||||
|
public sealed record PipelineHealth(
|
||||||
|
int EventsTracked,
|
||||||
|
int SportsCovered,
|
||||||
|
int SnapshotsLast24h,
|
||||||
|
int SnapshotsTotal,
|
||||||
|
int AnomaliesLast24h,
|
||||||
|
int AnomaliesTotal,
|
||||||
|
DateTimeOffset? LastSnapshotAt,
|
||||||
|
bool UpcomingPollerEnabled,
|
||||||
|
bool LivePollerEnabled,
|
||||||
|
bool ResultsPollerEnabled,
|
||||||
|
bool AnomalyDetectionEnabled)
|
||||||
|
{
|
||||||
|
/// <summary>True once anything has been captured — gates the empty state.</summary>
|
||||||
|
public bool HasData => SnapshotsTotal > 0 || EventsTracked > 0;
|
||||||
|
|
||||||
|
/// <summary>Empty snapshot used as the initial render state before data loads.</summary>
|
||||||
|
public static PipelineHealth Empty { get; } = new(
|
||||||
|
EventsTracked: 0, SportsCovered: 0,
|
||||||
|
SnapshotsLast24h: 0, SnapshotsTotal: 0,
|
||||||
|
AnomaliesLast24h: 0, AnomaliesTotal: 0,
|
||||||
|
LastSnapshotAt: null,
|
||||||
|
UpcomingPollerEnabled: false, LivePollerEnabled: false,
|
||||||
|
ResultsPollerEnabled: false, AnomalyDetectionEnabled: false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Marathon.UI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository-backed implementation of <see cref="IPipelineHealthService"/>. Composes
|
||||||
|
/// server-side counts (no full-table materialisation) with the snapshot freshness and
|
||||||
|
/// the worker-toggle state. Scoped — captures the per-circuit repository scope.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PipelineHealthService : IPipelineHealthService
|
||||||
|
{
|
||||||
|
private readonly IEventRepository _events;
|
||||||
|
private readonly ISnapshotRepository _snapshots;
|
||||||
|
private readonly IAnomalyRepository _anomalies;
|
||||||
|
private readonly IOptionsMonitor<WorkerOptions> _workers;
|
||||||
|
|
||||||
|
public PipelineHealthService(
|
||||||
|
IEventRepository events,
|
||||||
|
ISnapshotRepository snapshots,
|
||||||
|
IAnomalyRepository anomalies,
|
||||||
|
IOptionsMonitor<WorkerOptions> workers)
|
||||||
|
{
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
|
||||||
|
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
|
||||||
|
_workers = workers ?? throw new ArgumentNullException(nameof(workers));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PipelineHealth> GetAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var since24h = MoscowTime.Now.AddHours(-24);
|
||||||
|
|
||||||
|
var eventsTracked = await _events.CountAsync(ct).ConfigureAwait(false);
|
||||||
|
var sports = await _events.ListDistinctSportCodesAsync(ct).ConfigureAwait(false);
|
||||||
|
var snapshotsTotal = await _snapshots.CountSinceAsync(DateTimeOffset.MinValue, ct).ConfigureAwait(false);
|
||||||
|
var snapshots24h = await _snapshots.CountSinceAsync(since24h, ct).ConfigureAwait(false);
|
||||||
|
var anomaliesTotal = await _anomalies.CountSinceAsync(DateTimeOffset.MinValue, ct).ConfigureAwait(false);
|
||||||
|
var anomalies24h = await _anomalies.CountSinceAsync(since24h, ct).ConfigureAwait(false);
|
||||||
|
var lastSnapshotAt = await _snapshots.GetLatestCapturedAtAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var w = _workers.CurrentValue;
|
||||||
|
|
||||||
|
return new PipelineHealth(
|
||||||
|
EventsTracked: eventsTracked,
|
||||||
|
SportsCovered: sports.Count,
|
||||||
|
SnapshotsLast24h: snapshots24h,
|
||||||
|
SnapshotsTotal: snapshotsTotal,
|
||||||
|
AnomaliesLast24h: anomalies24h,
|
||||||
|
AnomaliesTotal: anomaliesTotal,
|
||||||
|
LastSnapshotAt: lastSnapshotAt,
|
||||||
|
UpcomingPollerEnabled: w.UpcomingPollerEnabled,
|
||||||
|
LivePollerEnabled: w.LivePollerEnabled,
|
||||||
|
ResultsPollerEnabled: w.ResultsPollerEnabled,
|
||||||
|
AnomalyDetectionEnabled: w.AnomalyDetectionEnabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ public static class UiServicesExtensions
|
|||||||
services.AddScoped<IBetJournalService, BetJournalService>();
|
services.AddScoped<IBetJournalService, BetJournalService>();
|
||||||
services.AddScoped<IBacktestService, BacktestService>();
|
services.AddScoped<IBacktestService, BacktestService>();
|
||||||
services.AddScoped<IDashboardSummaryService, DashboardSummaryService>();
|
services.AddScoped<IDashboardSummaryService, DashboardSummaryService>();
|
||||||
|
services.AddScoped<IPipelineHealthService, PipelineHealthService>();
|
||||||
|
|
||||||
// Settings writer — file path is host-resolved.
|
// Settings writer — file path is host-resolved.
|
||||||
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
|
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
|
||||||
|
|||||||
@@ -366,6 +366,29 @@ public sealed class RoundTripTests : IDisposable
|
|||||||
many.Keys.Select(k => k.Value).Should().BeEquivalentTo(new[] { "Q1", "Q3" });
|
many.Keys.Select(k => k.Value).Should().BeEquivalentTo(new[] { "Q1", "Q3" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Snapshot_GetLatestCapturedAt_ReturnsMostRecentOrNull()
|
||||||
|
{
|
||||||
|
// Empty store → null.
|
||||||
|
(await _snapshotRepo.GetLatestCapturedAtAsync()).Should().BeNull();
|
||||||
|
|
||||||
|
await _eventRepo.AddAsync(BuildEvent("L100"));
|
||||||
|
await _eventRepo.SaveChangesAsync();
|
||||||
|
|
||||||
|
var older = new DateTimeOffset(2026, 5, 1, 12, 0, 0, MoscowOffset);
|
||||||
|
var newer = new DateTimeOffset(2026, 5, 9, 18, 30, 0, MoscowOffset);
|
||||||
|
OddsSnapshot Snap(DateTimeOffset at) =>
|
||||||
|
new(new EventId("L100"), at, OddsSource.Live,
|
||||||
|
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.90m)) });
|
||||||
|
|
||||||
|
await _snapshotRepo.AddAsync(Snap(older));
|
||||||
|
await _snapshotRepo.AddAsync(Snap(newer));
|
||||||
|
await _snapshotRepo.SaveChangesAsync();
|
||||||
|
_fixture.DbContext.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
(await _snapshotRepo.GetLatestCapturedAtAsync()).Should().Be(newer);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static Event BuildEvent(string id, DateTimeOffset? scheduledAt = null) =>
|
private static Event BuildEvent(string id, DateTimeOffset? scheduledAt = null) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user