12208a4762
Frontend portion of Phase 7. Backend (commit a6ff368) had already shipped
the AnomalyDetector, DetectAnomaliesUseCase, AnomalyDetectionPoller, and
all DI wiring. This commit adds the user-facing surfaces.
New surfaces (Option A routing — folder-per-feature):
- Pages/Anomalies/AnomalyFeed.razor (@page /anomalies) — replaces the
Phase 5 placeholder with a severity-coded card stream, filter chips
(severity / sport / date), unread-count summary, 'Mark all read' action.
- Pages/Anomalies/Detail.razor (@page /anomalies/{id:guid}) — m-detail-header
lockup + AnomalyEvidence panel + back link to /events/{eventCode}.
New components:
- AnomalyCard.razor — severity-tinted left border (signal-red on High,
amber on Medium, neutral on Low) + SeverityBadge pill + sport icon +
pre→post tabular-mono rate strip + relative time. Click navigates.
- SeverityBadge.razor — small pill mapping score → bucket per backend
handoff (Low <0.45, Medium <0.60, High ≥0.60).
- AnomalyEvidence.razor — two-column pre/post panel with implied-prob
bars + raw rates; favourite-swap callout when argmax(p_pre) ≠ argmax(p_post);
signal-red 3px left border on the post column. Handles 2-way (no draw).
State + service split mirrors Phase 6's pattern:
- AnomalyViewModels.cs — AnomalyListItem / AnomalyDetailVm / Severity enum
/ AnomalyEvidenceSnapshot record. Severity computed in the view-model
from Score.
- IAnomalyBrowsingService / AnomalyBrowsingService — wraps IAnomalyRepository,
parses Anomaly.EvidenceJson into typed view-models, applies filters
client-side. Methods: ListAsync(filter, ct), GetByIdAsync(id, ct),
GetUnreadCountAsync(since, ct).
- AnomalyBrowsingState — Singleton holding AnomalyFilter (severity threshold,
sport set, date range) + LastSeenUtc + cached UnreadCount. OnChange event.
Nav badge:
- NavBody.razor subscribes to AnomalyBrowsingState.OnChange, renders a
pulsing red m-nav__badge when UnreadCount > 0. Badge resets when the
user clicks 'Mark all read' on the feed toolbar.
Settings toggle:
- Settings.razor — added Workers:AnomalyDetectionEnabled toggle (backend
added the flag). Localized via Settings.Worker.AnomalyDetectionEnabled.
- Marathon.UI.Services.WorkerOptions mirror — added AnomalyDetectionEnabled
(default true).
Localization: +30 RU/EN keys following the dot-segmented convention
(Anomaly.*, Settings.Worker.AnomalyDetectionEnabled). Full key parity verified.
Tests (+31 bUnit, all passing):
- AnomalyFeedTests, AnomalyDetailTests
- AnomalyCardTests, SeverityBadgeTests, AnomalyEvidenceTests
- FakeAnomalyBrowsingService support fake registered in MarathonTestContext.
Routing: deleted the Phase 5 Pages/Anomalies.razor placeholder; new feed
page lives at Pages/Anomalies/AnomalyFeed.razor.
Build: 0 warnings, 0 errors.
Tests: Domain 109 + Application 19 + Infrastructure 80 + UI 68 = 276/276
(baseline 245, +31 new bUnit tests, no regressions).
Phase 7 status: ✅ Done (backend + frontend both complete, awaiting review).
Known deferral: AnomalyBrowsingState.LastSeenUtc is in-memory only; the
unread-count badge resets on app restart. Acceptable for now; Phase 9 may
extend ISettingsWriter or add an ILastSeenStore.
111 lines
3.7 KiB
C#
111 lines
3.7 KiB
C#
using Marathon.Domain.Enums;
|
|
using Marathon.Domain.ValueObjects;
|
|
using Marathon.UI.Services;
|
|
|
|
namespace Marathon.UI.Tests.Support;
|
|
|
|
/// <summary>
|
|
/// In-memory <see cref="IAnomalyBrowsingService"/> for bUnit tests.
|
|
/// Seed via <see cref="Items"/> / <see cref="Detail"/>.
|
|
/// </summary>
|
|
public sealed class FakeAnomalyBrowsingService : IAnomalyBrowsingService
|
|
{
|
|
public List<AnomalyListItem> Items { get; } = new();
|
|
public AnomalyDetailVm? Detail { get; set; }
|
|
public List<int> SportCodes { get; } = new();
|
|
public int UnreadCount { get; set; }
|
|
public int ListCallCount { get; private set; }
|
|
public AnomalyFilter? LastFilter { get; private set; }
|
|
|
|
public Task<IReadOnlyList<AnomalyListItem>> ListAsync(AnomalyFilter filter, CancellationToken ct)
|
|
{
|
|
ListCallCount++;
|
|
LastFilter = filter;
|
|
IEnumerable<AnomalyListItem> q = Items;
|
|
|
|
if (filter.MinSeverity is { } min)
|
|
{
|
|
q = q.Where(i => AnomalySeverityRules.MeetsThreshold(i.Severity, min));
|
|
}
|
|
if (filter.SportCodes is { Count: > 0 } sports)
|
|
{
|
|
q = q.Where(i => sports.Contains(i.Sport.Value));
|
|
}
|
|
if (filter.From is { } from) q = q.Where(i => i.DetectedAt >= from);
|
|
if (filter.To is { } to) q = q.Where(i => i.DetectedAt <= to);
|
|
|
|
return Task.FromResult<IReadOnlyList<AnomalyListItem>>(
|
|
q.OrderByDescending(static i => i.DetectedAt).ToList());
|
|
}
|
|
|
|
public Task<AnomalyDetailVm?> GetByIdAsync(Guid id, CancellationToken ct)
|
|
=> Task.FromResult(Detail);
|
|
|
|
public Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
|
|
=> Task.FromResult(UnreadCount);
|
|
|
|
public Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
|
|
=> Task.FromResult<IReadOnlyList<int>>(SportCodes);
|
|
|
|
/// <summary>Builds an <see cref="AnomalyListItem"/> for tests with sane defaults.</summary>
|
|
public static AnomalyListItem MakeItem(
|
|
Guid? id = null,
|
|
string eventCode = "EV-A",
|
|
string title = "Arsenal vs Chelsea",
|
|
int sport = 11,
|
|
string country = "ENG",
|
|
string league = "Premier",
|
|
DateTimeOffset? detectedAt = null,
|
|
decimal score = 0.55m,
|
|
int gapSeconds = 90,
|
|
decimal? preWin1 = 1.30m,
|
|
decimal? preDraw = null,
|
|
decimal? preWin2 = 4.00m,
|
|
decimal? postWin1 = 4.00m,
|
|
decimal? postDraw = null,
|
|
decimal? postWin2 = 1.30m,
|
|
AnomalyFavourite preFav = AnomalyFavourite.Side1,
|
|
AnomalyFavourite postFav = AnomalyFavourite.Side2,
|
|
bool isTwoWay = true)
|
|
=> new(
|
|
id ?? Guid.NewGuid(),
|
|
new EventId(eventCode),
|
|
title,
|
|
new SportCode(sport),
|
|
country,
|
|
league,
|
|
detectedAt ?? DateTimeOffset.UtcNow.AddMinutes(-2),
|
|
score,
|
|
AnomalySeverityRules.FromScore(score),
|
|
AnomalyKind.SuspensionFlip,
|
|
gapSeconds,
|
|
preWin1,
|
|
preDraw,
|
|
preWin2,
|
|
postWin1,
|
|
postDraw,
|
|
postWin2,
|
|
preFav,
|
|
postFav,
|
|
isTwoWay);
|
|
|
|
public static AnomalyEvidenceSnapshot MakeSnapshot(
|
|
DateTimeOffset? capturedAt = null,
|
|
decimal? rate1 = 1.30m,
|
|
decimal? rateDraw = null,
|
|
decimal? rate2 = 4.00m,
|
|
decimal? p1 = 0.755m,
|
|
decimal? pDraw = null,
|
|
decimal? p2 = 0.245m,
|
|
AnomalyFavourite favourite = AnomalyFavourite.Side1)
|
|
=> new(
|
|
capturedAt ?? DateTimeOffset.UtcNow.AddMinutes(-3),
|
|
rate1,
|
|
rateDraw,
|
|
rate2,
|
|
p1,
|
|
pDraw,
|
|
p2,
|
|
favourite);
|
|
}
|