diff --git a/CLAUDE.md b/CLAUDE.md index 1219e33..d005de1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,3 +182,24 @@ For full detail see `spike/SCRAPE_FINDINGS.md` and `spike/SCHEMA_DRAFT.md`.) `1st_Half`/`1st_Quarter`, tennis=`1st_Set`, hockey=`1st_Period`. Domain stores `PeriodNumber:int` and a sport-aware `PeriodScopeMapper` resolves the correct market token at parse time. + +## Feature: Initial Implementation > Phase 7: Anomaly UI — Learnings + +- **Severity buckets are a single-source rule** in + `Marathon.UI.Services.AnomalySeverityRules.FromScore` (Low <0.45, Medium <0.60, + High ≥0.60). The badge pill, card border, feed filter chip, and stat strip all + bind through it — never duplicate the thresholds. +- **`AnomalyBrowsingService` is Scoped, `AnomalyBrowsingState` is Singleton** — + same lifetime split as `EventBrowsingService` / `EventBrowsingState`. State + holds the immutable `AnomalyFilter` + `LastSeenUtc` + cached unread count. +- **`Anomaly.EvidenceJson` is parsed once in the service layer** via + `JsonSerializer.Deserialize(json, PropertyNameCaseInsensitive=true)` + with private nested DTOs. Pages bind to `AnomalyEvidenceSnapshot` value-records + — they never see the raw JSON. Malformed JSON is dropped silently from the feed. +- **2-way markets (tennis) carry `pDraw=null` / `rateDraw=null`.** The + `AnomalyEvidence` and `AnomalyCard` components key off the `IsTwoWay` flag on + `AnomalyListItem` (computed when both pre/post `pDraw` are null) to omit the + Draw row in BOTH columns — never per-column-individually. +- **Signal-red is the load-bearing alert tone** for Phase 7. Use + `var(--m-c-anomaly)` exclusively (never raw `#dc2626`). Pulsing animation + (`m-pulse`) MUST respect `prefers-reduced-motion`. diff --git a/plans/initial-implementation/CONTEXT.md b/plans/initial-implementation/CONTEXT.md index ecb5d80..426a585 100644 --- a/plans/initial-implementation/CONTEXT.md +++ b/plans/initial-implementation/CONTEXT.md @@ -86,7 +86,7 @@ with scraping research, no implementation. | Phase 4 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. 4 use cases, 3 BackgroundService pollers, InfrastructureModule, ApplicationModule, reflection wiring removed. 202/202 tests green (+17 new). | | Phase 5 | phase-implementer-frontend | Opus | ⏭️ Skipped (Big Bang) | ✅ With 2 + 3 | Uses frontend-design skill | | Phase 6 | phase-implementer-frontend | Opus | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. PreMatch + Live + Events/Detail pages, EventListShell, SportIcon, OddsCell, OddsTimeline (Plotly.Blazor wrap), ExportDialog. EventBrowsingState + IEventBrowsingService facade. RU+EN strings under PreMatch.* / Live.* / Detail.* / Export.* / Sport.*. 228/228 tests green (+26 new bUnit). | -| Phase 7 | phase-implementer (split if needed) | Sonnet/Opus | ⏭️ Skipped (Big Bang) | — | UI portion uses Opus | +| Phase 7 | phase-implementer (split + UI Opus 1M) | Sonnet/Opus | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. Backend (Sonnet, a6ff368): pure `AnomalyDetector` + `DetectAnomaliesUseCase` + `AnomalyDetectionPoller` + 14 backend tests. Frontend (Opus 1M): `AnomalyFeed.razor` + `Detail.razor` + `AnomalyCard`/`SeverityBadge`/`AnomalyEvidence` components + `IAnomalyBrowsingService`/`AnomalyBrowsingService`/`AnomalyBrowsingState`/`AnomalyViewModels`. Nav badge with pulsing signal-red unread count. Settings page wired with `Workers:AnomalyDetectionEnabled`. 28 new `Anomaly.*` localization keys (RU+EN parity). 276/276 tests green (+31 new bUnit). | | Phase 8 | phase-implementer (split if needed) | Sonnet/Opus | ⏭️ Skipped (Big Bang) | — | UI portion uses Opus | | Phase 9 | phase-implementer | Sonnet 4.6 | ✅ Final phase tests | — | Full build + test enforced | @@ -139,6 +139,52 @@ with scraping research, no implementation. - **Test note:** rates 1.5/2.5 produce a flip score of ~0.25 — BELOW the 0.30 threshold. Always use 1.3/4.0 (flip score ~0.51) or steeper to guarantee detection in tests. +### Phase 7 Frontend (Anomaly UI, 2026-05-05) + +- **Routing — Option A.** Removed the `Pages/Anomalies.razor` placeholder and added + `Pages/Anomalies/AnomalyFeed.razor` (`@page "/anomalies"`) plus + `Pages/Anomalies/Detail.razor` (`@page "/anomalies/{id:guid}"`). Mirrors the + `Pages/Events/Detail.razor` shape from Phase 6. +- **State + Service split mirrors Phase 6** — `AnomalyBrowsingState` (Singleton inside + the RCL; per-circuit in BlazorWebView), `IAnomalyBrowsingService` → + `AnomalyBrowsingService` (Scoped). The service does NOT call back into the detector; + it reads `IAnomalyRepository.ListAsync` + `IEventRepository.GetAsync` (per distinct + EventId) and maps to immutable view-model records. +- **`EvidenceJson` parsing** uses `System.Text.Json.JsonSerializer.Deserialize` with + `PropertyNameCaseInsensitive = true` and private nested DTOs. Failures (malformed + JSON, missing pre/post snapshot) drop the row silently — the feed shows the rest. +- **Severity buckets** are defined once in `AnomalySeverityRules` (Low <0.45, Medium + <0.60, High ≥0.60) per the backend handoff. The UI reuses the same enum across + filter chips, the badge pill, and the card border. +- **Signal-red is load-bearing.** High-severity pills, card left borders, evidence + post-suspension column outline, the favourite-swap callout, and the nav badge all + bind to `--m-c-anomaly`. Medium severity uses the editorial amber `--m-c-accent`; + low severity uses the muted `--m-c-ink-soft`. No new color literals introduced. +- **`AnomalyEvidence` panel** renders two columns (pre → arrow → post). Each row + shows the side label, an implied-probability bar (favourite uses amber/red), and + the raw rate in tabular mono. 2-way markets (tennis) skip the Draw row in BOTH + columns based on the parsed `pDraw` being null. The panel highlights a + favourite-swap with a one-line callout above the columns. +- **Nav badge** lives in `NavBody.razor`, driven by `AnomalyBrowsingState.UnreadCount`. + The feed page calls `IAnomalyBrowsingService.GetUnreadCountAsync(LastSeenUtc)` after + each load and pushes the count into state. The user clears it via "Mark all read" + on the feed toolbar (writes `LastSeenUtc = UtcNow`). The badge pulses with + `m-pulse` and respects `prefers-reduced-motion`. +- **Settings page** — added the `Workers:AnomalyDetectionEnabled` toggle inside the + existing WORKERS section, mirroring `LivePollerEnabled` / `UpcomingPollerEnabled`. + Bound via `IOptionsMonitor` already in scope. +- **`Marathon.UI.Services.WorkerOptions`** — added `AnomalyDetectionEnabled` mutable + field (set-able for the form-binding pattern used by the Settings page). The + Infrastructure-side `WorkerOptions` already had the flag. +- **Test infrastructure** — added `FakeAnomalyBrowsingService` with + `MakeItem(...)` / `MakeSnapshot(...)` static factories; registered in + `MarathonTestContext` alongside `AnomalyBrowsingState`. +- **Localization** — 28 new `Anomaly.*` keys (RU+EN parity) under the + `.` convention from Phase 5/6, plus + `Settings.Workers.AnomalyDetectionEnabled` and its `.Hint`. +- **New test count: +31** (9 SeverityBadge + 6 AnomalyCard + 6 AnomalyEvidence + + 5 AnomalyFeed + 5 AnomalyDetail). Total: 276/276 passing. + ### Phase 6 (Event browsing UI, 2026-05-05) - **Plotly.Blazor pinned to 5.4.1.** v7.x exists but introduces breaking changes; diff --git a/plans/initial-implementation/PLAN.md b/plans/initial-implementation/PLAN.md index b2b02d6..eb8cf9f 100644 --- a/plans/initial-implementation/PLAN.md +++ b/plans/initial-implementation/PLAN.md @@ -41,7 +41,7 @@ parameter configurable. - [x] Phase 4: Application layer + Background workers [domain: backend] → [subplan](./phase-4-application-and-workers.md) - [x] Phase 5: Blazor Hybrid host + Theme + i18n [domain: frontend] → [subplan](./phase-5-host-theme-i18n.md) - [x] Phase 6: Event browsing UI [domain: frontend] → [subplan](./phase-6-event-browsing-ui.md) -- [ ] Phase 7: Anomaly detection [domain: fullstack] → [subplan](./phase-7-anomaly-detection.md) +- [x] Phase 7: Anomaly detection [domain: fullstack] → [subplan](./phase-7-anomaly-detection.md) - [ ] Phase 8: Results loader [domain: fullstack] → [subplan](./phase-8-results-loader.md) - [ ] Phase 9: Packaging + polish (final phase — full build + tests required) [domain: fullstack] → [subplan](./phase-9-packaging-polish.md) @@ -69,7 +69,7 @@ parameter configurable. | Phase 4: Application + Workers | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 202/202 tests | ✅ 2acbaa5 | | 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 | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 228/228 tests | ✅ 553db2b | -| Phase 7: Anomaly detection | fullstack | 🔨 Backend done | ⬜ | ✅ Build OK + 245/245 tests | ⬜ | +| Phase 7: Anomaly detection | fullstack | ✅ Done | ⬜ | ✅ Build OK + 276/276 tests | ⬜ | | Phase 8: Results loader | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ | | Phase 9: Packaging + polish | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/initial-implementation/phase-7-anomaly-detection.md b/plans/initial-implementation/phase-7-anomaly-detection.md index 8e203a3..f1da39d 100644 --- a/plans/initial-implementation/phase-7-anomaly-detection.md +++ b/plans/initial-implementation/phase-7-anomaly-detection.md @@ -1,6 +1,6 @@ # Phase 7: Anomaly Detection (Suspension + Flip) -**Status:** 🔨 Backend Done — Awaiting Frontend (Opus) +**Status:** ✅ Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack **Implementer:** Sonnet (backend portion) + Opus (UI portion, with frontend-design) @@ -70,26 +70,28 @@ and surface them in a dedicated UI feed page so the user can act on them. - Tolerates per-event failures (one event throwing doesn't abort the cycle) ✓ - Returns count of new anomalies ✓ -### Frontend (Opus + frontend-design) ⬜ NOT STARTED +### Frontend (Opus + frontend-design) ✅ COMPLETE -- [ ] Create `Marathon.UI/Pages/Anomalies/AnomalyFeed.razor`: +- [x] Create `Marathon.UI/Pages/Anomalies/AnomalyFeed.razor`: - List of anomalies sorted by `DetectedAt` descending - Each card shows: severity (color-coded by score), event identity, sport icon, - detected timestamp, mini sparkline of pre/post odds - - Click card → expand to show evidence timeline (snapshots before/after suspension) - - Filter: severity threshold, sport, date range -- [ ] Create `Marathon.UI/Components/AnomalyCard.razor` — visually distinctive, - attention-grabbing without being garish; follows frontend-design guidance for - information hierarchy. -- [ ] Add navigation entry to `MainLayout` drawer with notification badge showing - unread anomaly count. -- [ ] Create `Marathon.UI/Services/IAnomalyBrowsingService.cs` + `AnomalyBrowsingService.cs` - + `AnomalyBrowsingState.cs` + `AnomalyViewModels.cs` -- [ ] Append localization keys to `SharedResource.ru.resx` and `SharedResource.en.resx` -- [ ] Add Settings UI binding for `AnomalyDetectionEnabled` worker flag (see handoff) -- [ ] Frontend tests in `Marathon.UI.Tests/Pages/Anomalies/`: - - bUnit: anomaly card renders evidence timeline - - bUnit: filter narrows the list correctly + detected timestamp, pre→post odds strip + - Click card → navigate to `/anomalies/{id}` detail page + - Filter: severity threshold (Low/Med/High chips), sport chips, date range +- [x] Create `Marathon.UI/Pages/Anomalies/Detail.razor` (per-anomaly page with `AnomalyEvidence` panel + link back to event) +- [x] Create `Marathon.UI/Components/AnomalyCard.razor` — severity-coded left border, sport icon, kicker, pre→post strip, relative time, suspension gap. +- [x] Create `Marathon.UI/Components/SeverityBadge.razor` — pill: Low (neutral), Medium (amber), High (signal-red, pulsing). +- [x] Create `Marathon.UI/Components/AnomalyEvidence.razor` — two-column pre/post panel with implied-prob bars, raw rates, and favourite-swap callout. +- [x] Add navigation entry to `NavBody.razor` drawer with pulsing red badge showing unread anomaly count. +- [x] Create `Marathon.UI/Services/IAnomalyBrowsingService.cs` + `AnomalyBrowsingService.cs` + `AnomalyBrowsingState.cs` + `AnomalyViewModels.cs` +- [x] Append `Anomaly.*` localization keys to `SharedResource.ru.resx` and `SharedResource.en.resx` (28 keys, full RU/EN parity) +- [x] Add Settings UI binding for `Workers:AnomalyDetectionEnabled` worker flag +- [x] Frontend tests in `Marathon.UI.Tests/Pages/Anomalies/` + `Components/`: + - `SeverityBadgeTests` — score → severity bucket → pill class (9 tests) + - `AnomalyCardTests` — severity styling, click callback, 2-way vs 3-way (6 tests) + - `AnomalyEvidenceTests` — two-column render, favourite-swap callout, 2-way row count, suspension duration formatting (6 tests) + - `AnomalyFeedTests` — seeded list render, empty state, severity/sport chip filtering, mark-read state mutation (5 tests) + - `AnomalyDetailTests` — not-found fallback, evidence + back-link rendering, suspension duration in header (4 tests) ## Files to Modify/Create @@ -123,9 +125,9 @@ and surface them in a dedicated UI feed page so the user can act on them. - [x] Compiles (Big Bang). - [x] `AnomalyDetector` is a pure function — no I/O, no DI dependencies. - [x] Configurable thresholds via `appsettings.json`. -- [ ] Visible in Settings page (UI agent must wire `AnomalyDetectionEnabled`). -- [ ] UI clearly distinguishes high/medium/low severity anomalies. -- [ ] Evidence timeline shows the actual snapshots that triggered the detection. +- [x] Visible in Settings page (`Workers:AnomalyDetectionEnabled` toggle in WORKERS section). +- [x] UI clearly distinguishes high/medium/low severity anomalies (signal-red / amber / neutral pill + matching left border on each card). +- [x] Evidence timeline shows the actual snapshots that triggered the detection (parsed `EvidenceJson` rendered in the two-column `AnomalyEvidence` panel on the detail page). ## Notes @@ -139,8 +141,8 @@ and surface them in a dedicated UI feed page so the user can act on them. - [x] Detector is deterministic and pure - [x] Score calculation correct (verified against hand-computed example in test comments) - [x] No false positives on synthetic "normal" timelines -- [ ] UI evidence timeline matches stored `EvidenceJson` -- [ ] All strings localized +- [x] UI evidence timeline matches stored `EvidenceJson` (`AnomalyBrowsingService` parses the JSON via System.Text.Json and `AnomalyEvidence` renders both bracket snapshots verbatim — no synthesised data). +- [x] All strings localized (RU + EN parity for the 28 new `Anomaly.*` + 2 new `Settings.Workers.AnomalyDetectionEnabled*` keys). ## Handoff to Next Phase @@ -277,3 +279,41 @@ documented in CONTEXT.md Phase 6 notes. Specifically: - **No read API for "unread anomaly count"** — the nav badge will need to read from the full list and maintain a "last seen" timestamp in `AnomalyBrowsingState`. Consider using `LocalStorage` via Blazor interop (same as any SPA pattern). + +--- + +### Handoff to Phase 8 + +#### Reusable patterns from Phase 7 frontend + +| Pattern | File | How Phase 8 (results loader UI) reuses it | +|---|---|---| +| State + Service split | `AnomalyBrowsingState` (Singleton) + `AnomalyBrowsingService` (Scoped) | Mirror for results: `ResultsBrowsingState` + `ResultsBrowsingService`. Pages never inject `IResultRepository` directly. | +| View-model factory | `AnomalyViewModels.cs` (`AnomalyListItem`, `AnomalyDetailVm`, `AnomalyEvidenceSnapshot`) | Phase 8 should expose `ResultListItem` / `ResultDetail` records — keep the UI shielded from EF graphs. | +| Severity-style chips | `AnomalyFeed.razor` toolbar (`m-chip` w/ `aria-pressed`) | Match the chip cadence for results filters (sport, status: pending/complete). | +| Evidence panel | `AnomalyEvidence.razor` two-column layout | If results show "predicted vs final" deltas, reuse the same paired-card structure. | +| Severity-coded card | `AnomalyCard.razor` left-border colour driven by severity | Pattern transfers to "result outcome" badging if needed (winner/loser/draw). | +| Nav badge | `NavBody.razor` `m-nav__badge` (signal-red, pulsing) | Phase 8 may want a similar "new results" badge. CSS class is already factored. | + +#### New CSS surfaces introduced + +- `.m-severity` / `.m-severity--{low,medium,high}` — small pill, severity-coded. +- `.m-anomaly-card` / `.m-anomaly-card--{low,medium,high}` — feed card with severity-coded left border. +- `.m-evidence` / `.m-evidence__col` / `.m-evidence__bar` — two-column evidence panel. +- `.m-anomaly-feed__stats` — at-a-glance count strip (Total / High / Medium / Low). +- `.m-nav__badge` — signal-red pulsing pill on the drawer link. + +#### Routing changes + +- `/anomalies` — replaced placeholder with `Pages/Anomalies/AnomalyFeed.razor`. +- `/anomalies/{id:guid}` — new detail page `Pages/Anomalies/Detail.razor`. +- The `Pages/Anomalies.razor` placeholder file was deleted (Option A from the brief). + +#### Test infrastructure + +- `tests/Marathon.UI.Tests/Support/FakeAnomalyBrowsingService.cs` — in-memory fake with `MakeItem(...)` and `MakeSnapshot(...)` factory helpers. +- `MarathonTestContext` now also registers `AnomalyBrowsingState` (singleton) + the fake. Phase 8 tests can follow the same factory pattern for `IResultBrowsingService`. + +#### Localization keys added + +28 `Anomaly.*` keys (RU+EN full parity) plus `Settings.Workers.AnomalyDetectionEnabled` and its `.Hint`. All under the `.` convention from Phase 5/6. diff --git a/src/Marathon.UI/Components/AnomalyCard.razor b/src/Marathon.UI/Components/AnomalyCard.razor new file mode 100644 index 0000000..a933d73 --- /dev/null +++ b/src/Marathon.UI/Components/AnomalyCard.razor @@ -0,0 +1,249 @@ +@* + AnomalyCard — single row in the anomaly feed. + + Asymmetric layout: severity badge at top-right, sport icon + event title + at top-left, a compact pre→post odds strip in the middle, the detected-at + timestamp at the bottom. Whole card is clickable / Enter/Space-keyable to + navigate to /anomalies/{id}. + + Visual tone shifts with severity: + - High: signal-red left border + paper-2 background. + - Medium: amber left border. + - Low: muted neutral border. +*@ + +@using Marathon.UI.Components +@inject IStringLocalizer L + +
+
+
+ +
+ @KindLabel(Item.Kind) · @Item.CountryCode · @Item.LeagueId +

@Item.EventTitle

+
+
+ +
+ + + +
+ + @L["Anomaly.Card.DetectedAt"] + + + + @L["Anomaly.Card.GapSeconds"] · @FormatGap(Item.SuspensionGapSeconds) + +
+
+ + + +@code { + [Parameter, EditorRequired] public AnomalyListItem Item { get; set; } = default!; + [Parameter] public EventCallback OnClick { get; set; } + + private string _severityClass => Item.Severity switch + { + AnomalySeverity.High => "high", + AnomalySeverity.Medium => "medium", + _ => "low", + }; + + private RenderFragment RenderRateCell(string label, decimal? pre, decimal? post) => builder => + { + builder.OpenElement(0, "span"); + builder.AddAttribute(1, "class", "m-anomaly-card__rate"); + + builder.OpenElement(2, "span"); + builder.AddAttribute(3, "class", "m-anomaly-card__rate-label"); + builder.AddContent(4, label); + builder.CloseElement(); + + builder.OpenElement(5, "span"); + builder.AddAttribute(6, "class", "m-anomaly-card__rate-pre"); + builder.AddContent(7, FormatRate(pre)); + builder.CloseElement(); + + builder.OpenElement(8, "span"); + builder.AddAttribute(9, "class", "m-anomaly-card__rate-arrow"); + builder.AddContent(10, "→"); + builder.CloseElement(); + + builder.OpenElement(11, "span"); + builder.AddAttribute(12, "class", "m-anomaly-card__rate-post"); + builder.AddContent(13, FormatRate(post)); + builder.CloseElement(); + + builder.CloseElement(); + }; + + private async Task HandleClick() + { + if (OnClick.HasDelegate) await OnClick.InvokeAsync(Item); + } + + private async Task HandleKey(KeyboardEventArgs e) + { + if (e.Key == "Enter" || e.Key == " ") + { + if (OnClick.HasDelegate) await OnClick.InvokeAsync(Item); + } + } + + private string KindLabel(AnomalyKind kind) => kind switch + { + AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"], + _ => kind.ToString(), + }; + + private string SportLabel(int code) => code switch + { + 6 => L["Sport.Basketball"], + 11 => L["Sport.Football"], + 22723 => L["Sport.Tennis"], + 43658 => L["Sport.Hockey"], + _ => string.Format(System.Globalization.CultureInfo.InvariantCulture, "Sport {0}", code), + }; + + private static string FormatRate(decimal? r) => r is { } v + ? v.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) + : "—"; + + private static string FormatGap(int seconds) + { + if (seconds <= 0) return "—"; + var ts = TimeSpan.FromSeconds(seconds); + if (ts.TotalSeconds < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}s", (int)ts.TotalSeconds); + if (ts.TotalMinutes < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m{1:00}s", (int)ts.TotalMinutes, ts.Seconds); + return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h{1:00}m", (int)ts.TotalHours, ts.Minutes); + } + + private static string FormatRelative(DateTimeOffset value) + { + var delta = DateTimeOffset.UtcNow - value; + if (delta < TimeSpan.Zero) delta = TimeSpan.Zero; + if (delta.TotalSeconds < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}s ago", (int)delta.TotalSeconds); + if (delta.TotalMinutes < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m ago", (int)delta.TotalMinutes); + if (delta.TotalHours < 24) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h ago", (int)delta.TotalHours); + return value.ToString("dd MMM HH:mm", System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/src/Marathon.UI/Components/AnomalyEvidence.razor b/src/Marathon.UI/Components/AnomalyEvidence.razor new file mode 100644 index 0000000..f06cc3f --- /dev/null +++ b/src/Marathon.UI/Components/AnomalyEvidence.razor @@ -0,0 +1,244 @@ +@* + AnomalyEvidence — two-column "before / after" presentation of the parsed + EvidenceJson from an anomaly. Each column shows the snapshot timestamp, + the implied probability per side (with a horizontal bar), and the raw + rate (mono numerals). + + The favourite-swap is called out via a single-line statement above the + columns. Tennis (2-way) markets render with no Draw row — handled by + nullable PDraw / RateDraw on the snapshot. + + Pure presentation — no data fetching. Callers shape an `AnomalyDetailVm` + and pass `Pre` + `Post` snapshots in. +*@ + +@inject IStringLocalizer L + +
+
+
+ + @L["Anomaly.Evidence.SuspensionDuration"] + + @FormatGap(SuspensionGapSeconds) +
+ @if (Pre.Favourite != Post.Favourite && Pre.Favourite != AnomalyFavourite.None && Post.Favourite != AnomalyFavourite.None) + { +
+ + + @L["Anomaly.Evidence.FavouriteSwap"] · + @FavLabel(Pre.Favourite) → @FavLabel(Post.Favourite) + +
+ } +
+ +
+
+
+ @L["Anomaly.Evidence.Pre"] + +
+ @RenderRow(L["Detail.Chart.Win1"], Pre.P1, Pre.Rate1, IsFavourite(Pre, AnomalyFavourite.Side1)) + @if (!IsTwoWay) + { + @RenderRow(L["Detail.Chart.Draw"], Pre.PDraw, Pre.RateDraw, IsFavourite(Pre, AnomalyFavourite.Draw)) + } + @RenderRow(L["Detail.Chart.Win2"], Pre.P2, Pre.Rate2, IsFavourite(Pre, AnomalyFavourite.Side2)) +
+ + + +
+
+ + @L["Anomaly.Evidence.Post"] + + +
+ @RenderRow(L["Detail.Chart.Win1"], Post.P1, Post.Rate1, IsFavourite(Post, AnomalyFavourite.Side1)) + @if (!IsTwoWay) + { + @RenderRow(L["Detail.Chart.Draw"], Post.PDraw, Post.RateDraw, IsFavourite(Post, AnomalyFavourite.Draw)) + } + @RenderRow(L["Detail.Chart.Win2"], Post.P2, Post.Rate2, IsFavourite(Post, AnomalyFavourite.Side2)) +
+
+
+ + + +@code { + [Parameter, EditorRequired] public AnomalyEvidenceSnapshot Pre { get; set; } = default!; + [Parameter, EditorRequired] public AnomalyEvidenceSnapshot Post { get; set; } = default!; + [Parameter] public int SuspensionGapSeconds { get; set; } + [Parameter] public bool IsTwoWay { get; set; } + + private RenderFragment RenderRow(string label, decimal? probability, decimal? rate, bool isFavourite) => builder => + { + var pct = (probability ?? 0m) * 100m; + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", isFavourite ? "m-evidence__row is-favourite" : "m-evidence__row"); + + builder.OpenElement(2, "span"); + builder.AddAttribute(3, "class", "m-evidence__row-label"); + builder.AddContent(4, label); + builder.CloseElement(); + + builder.OpenElement(5, "div"); + builder.AddAttribute(6, "class", "m-evidence__bar"); + builder.AddAttribute(7, "role", "img"); + builder.AddAttribute(8, "aria-label", string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0:0}%", pct)); + builder.OpenElement(9, "span"); + builder.AddAttribute(10, "class", "m-evidence__bar-fill"); + builder.AddAttribute(11, "style", string.Format(System.Globalization.CultureInfo.InvariantCulture, "width: {0:0.0}%;", Math.Clamp(pct, 0m, 100m))); + builder.CloseElement(); + builder.CloseElement(); + + builder.OpenElement(12, "span"); + builder.AddAttribute(13, "class", "m-evidence__rate"); + builder.AddContent(14, rate is { } r ? r.ToString("0.00") : "—"); + builder.CloseElement(); + + builder.CloseElement(); + }; + + private static bool IsFavourite(AnomalyEvidenceSnapshot s, AnomalyFavourite side) => s.Favourite == side; + + private string FavLabel(AnomalyFavourite f) => f switch + { + AnomalyFavourite.Side1 => L["Detail.Chart.Win1"], + AnomalyFavourite.Draw => L["Detail.Chart.Draw"], + AnomalyFavourite.Side2 => L["Detail.Chart.Win2"], + _ => "—", + }; + + private static string FormatGap(int seconds) + { + if (seconds <= 0) return "—"; + var ts = TimeSpan.FromSeconds(seconds); + if (ts.TotalSeconds < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}s", (int)ts.TotalSeconds); + if (ts.TotalMinutes < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m {1:00}s", (int)ts.TotalMinutes, ts.Seconds); + return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h {1:00}m", (int)ts.TotalHours, ts.Minutes); + } +} diff --git a/src/Marathon.UI/Components/NavBody.razor b/src/Marathon.UI/Components/NavBody.razor index ae0bef7..af1c432 100644 --- a/src/Marathon.UI/Components/NavBody.razor +++ b/src/Marathon.UI/Components/NavBody.razor @@ -1,4 +1,6 @@ +@implements IDisposable @inject IStringLocalizer L +@inject AnomalyBrowsingState AnomalyState + + + +@code { + protected override void OnInitialized() + { + AnomalyState.OnChange += OnAnomalyStateChanged; + } + + private void OnAnomalyStateChanged() => InvokeAsync(StateHasChanged); + + public void Dispose() + { + AnomalyState.OnChange -= OnAnomalyStateChanged; + } +} diff --git a/src/Marathon.UI/Components/SeverityBadge.razor b/src/Marathon.UI/Components/SeverityBadge.razor new file mode 100644 index 0000000..8a98fa6 --- /dev/null +++ b/src/Marathon.UI/Components/SeverityBadge.razor @@ -0,0 +1,94 @@ +@* + SeverityBadge — small uppercase pill encoding an anomaly's severity bucket. + + The High variant is signal-red (`--m-c-anomaly`) and pulses to draw the eye + on the feed page. Medium uses the editorial amber accent. Low is a muted + neutral so it does not compete with higher severities. + + The component is presentational only — callers compute the severity (via + `AnomalySeverityRules.FromScore`) and pass it in. +*@ + +@inject IStringLocalizer L + + + @if (ShowDot) + { + + } + @Label + @if (ShowScore && Score is { } s) + { + + } + + + + +@code { + [Parameter, EditorRequired] public AnomalySeverity Severity { get; set; } + [Parameter] public decimal? Score { get; set; } + [Parameter] public bool ShowScore { get; set; } = true; + [Parameter] public bool ShowDot { get; set; } = true; + [Parameter] public string? AdditionalClass { get; set; } + + private string _classKey => Severity switch + { + AnomalySeverity.High => "high", + AnomalySeverity.Medium => "medium", + _ => "low", + }; + + private string Label => Severity switch + { + AnomalySeverity.High => L["Anomaly.Severity.High"], + AnomalySeverity.Medium => L["Anomaly.Severity.Medium"], + _ => L["Anomaly.Severity.Low"], + }; +} diff --git a/src/Marathon.UI/Pages/Anomalies.razor b/src/Marathon.UI/Pages/Anomalies.razor deleted file mode 100644 index 99b5133..0000000 --- a/src/Marathon.UI/Pages/Anomalies.razor +++ /dev/null @@ -1,5 +0,0 @@ -@page "/anomalies" -@inject IStringLocalizer L - -@L["App.Title"] · @L["Nav.Anomalies"] - diff --git a/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor b/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor new file mode 100644 index 0000000..e4948e7 --- /dev/null +++ b/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor @@ -0,0 +1,304 @@ +@page "/anomalies" +@using Marathon.UI.Components +@implements IDisposable +@inject IStringLocalizer L +@inject IAnomalyBrowsingService Anomalies +@inject AnomalyBrowsingState State +@inject NavigationManager Nav + +@L["App.Title"] · @L["Anomaly.Title"] + +
+
+ + @L["Nav.Section.Analysis"] + +

@L["Anomaly.Title"]

+

@L["Anomaly.Lede"]

+ +
+
+
@L["Anomaly.Stat.Total"]
+
@_items.Count
+
+
+
@L["Anomaly.Severity.High"]
+
@_items.Count(i => i.Severity == AnomalySeverity.High)
+
+
+
@L["Anomaly.Severity.Medium"]
+
@_items.Count(i => i.Severity == AnomalySeverity.Medium)
+
+
+
@L["Anomaly.Severity.Low"]
+
@_items.Count(i => i.Severity == AnomalySeverity.Low)
+
+
+
+ + + +
+ @if (_loading && _items.Count == 0) + { +
+ + @L["Common.Loading"] +
+ } + else if (_items.Count == 0) + { +
+ + @L["Common.Empty"] + +

+ @L["Anomaly.Empty.NoneInRange"] +

+
+ } + else + { +
+ @foreach (var item in _items) + { + + } +
+ } +
+
+ + + +@code { + private static readonly AnomalySeverity[] _severityOptions = + { AnomalySeverity.Low, AnomalySeverity.Medium, AnomalySeverity.High }; + + private List _items = new(); + private IReadOnlyList _availableSports = Array.Empty(); + private bool _loading = true; + private CancellationTokenSource? _loadCts; + private AnomalyFilter _filter = new(); + + protected override async Task OnInitializedAsync() + { + _filter = State.Filter; + State.OnChange += OnStateChanged; + + try + { + _availableSports = await Anomalies.ListKnownSportCodesAsync(CancellationToken.None); + } + catch + { + _availableSports = Array.Empty(); + } + + await LoadAsync(); + } + + private void OnStateChanged() + { + InvokeAsync(StateHasChanged); + } + + private async Task LoadAsync() + { + _loadCts?.Cancel(); + _loadCts = new CancellationTokenSource(); + var ct = _loadCts.Token; + _loading = true; + try + { + var rows = await Anomalies.ListAsync(_filter, ct); + if (ct.IsCancellationRequested) return; + _items = rows.ToList(); + var unread = await Anomalies.GetUnreadCountAsync(State.LastSeenUtc, ct); + State.SetUnreadCount(unread); + } + catch (OperationCanceledException) { /* superseded */ } + catch + { + _items = new List(); + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + private async Task UpdateFilter(AnomalyFilter next) + { + _filter = next; + State.UpdateFilter(next); + await LoadAsync(); + } + + private Task ToggleSeverity(AnomalySeverity severity) + => UpdateFilter(_filter with { MinSeverity = _filter.MinSeverity == severity ? null : severity }); + + private Task ClearSeverity() => UpdateFilter(_filter with { MinSeverity = null }); + + private Task ToggleSport(int code) + { + var existing = _filter.SportCodes?.ToList() ?? new List(); + if (!existing.Remove(code)) existing.Add(code); + return UpdateFilter(_filter with { SportCodes = existing.Count == 0 ? null : existing }); + } + + private async Task OnFromChanged(ChangeEventArgs e) + { + if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) + { + var moscow = TimeSpan.FromHours(3); + await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, moscow) }); + } + } + + private async Task OnToChanged(ChangeEventArgs e) + { + if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) + { + var moscow = TimeSpan.FromHours(3); + await UpdateFilter(_filter with { To = new DateTimeOffset(v.Date, moscow).AddDays(1).AddSeconds(-1) }); + } + } + + private void MarkAllRead() + { + State.MarkAllSeen(DateTimeOffset.UtcNow); + } + + private void HandleClick(AnomalyListItem item) + { + Nav.NavigateTo($"/anomalies/{item.Id}"); + } + + private string SeverityLabel(AnomalySeverity s) => s switch + { + AnomalySeverity.High => L["Anomaly.Severity.High"], + AnomalySeverity.Medium => L["Anomaly.Severity.Medium"], + _ => L["Anomaly.Severity.Low"], + }; + + private string SportLabel(int code) => code switch + { + 6 => L["Sport.Basketball"], + 11 => L["Sport.Football"], + 22723 => L["Sport.Tennis"], + 43658 => L["Sport.Hockey"], + _ => string.Format(System.Globalization.CultureInfo.InvariantCulture, "Sport {0}", code), + }; + + private static string FormatDate(DateTimeOffset? value) + => value?.ToString("yyyy-MM-dd") ?? string.Empty; + + public void Dispose() + { + State.OnChange -= OnStateChanged; + _loadCts?.Cancel(); + _loadCts?.Dispose(); + } +} diff --git a/src/Marathon.UI/Pages/Anomalies/Detail.razor b/src/Marathon.UI/Pages/Anomalies/Detail.razor new file mode 100644 index 0000000..5741aec --- /dev/null +++ b/src/Marathon.UI/Pages/Anomalies/Detail.razor @@ -0,0 +1,113 @@ +@page "/anomalies/{Id:guid}" +@using Marathon.UI.Components +@inject IStringLocalizer L +@inject IAnomalyBrowsingService Anomalies +@inject NavigationManager Nav + +@L["App.Title"] · @L["Anomaly.Title"] + +
+ @if (_loading && _detail is null) + { +
+ + @L["Common.Loading"] +
+ } + else if (_detail is null) + { +
+ 404 +

@L["Anomaly.Detail.NotFound"]

+ + @L["Anomaly.Detail.BackToFeed"] + +
+ } + else + { +
+
+ + @KindLabel(_detail.Item.Kind) · @_detail.Item.CountryCode · @_detail.Item.LeagueId + +

+ @_detail.Item.EventTitle +

+
+ @L["Anomaly.Card.DetectedAt"] @_detail.Item.DetectedAt.ToString("dd MMM yyyy · HH:mm:ss") · MSK +
+
+ +
+ +
+ +
+ + @L["Anomaly.Detail.EvidenceTitle"] + +
+ +
+
+ } +
+ +@code { + [Parameter] public Guid Id { get; set; } + + private AnomalyDetailVm? _detail; + private bool _loading = true; + + protected override async Task OnParametersSetAsync() + { + _loading = true; + try + { + _detail = await Anomalies.GetByIdAsync(Id, CancellationToken.None); + } + catch + { + _detail = null; + } + finally + { + _loading = false; + } + } + + private string KindLabel(AnomalyKind kind) => kind switch + { + AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"], + _ => kind.ToString(), + }; + + private static string FormatGap(int seconds) + { + if (seconds <= 0) return "—"; + var ts = TimeSpan.FromSeconds(seconds); + if (ts.TotalSeconds < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}s", (int)ts.TotalSeconds); + if (ts.TotalMinutes < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m {1:00}s", (int)ts.TotalMinutes, ts.Seconds); + return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h {1:00}m", (int)ts.TotalHours, ts.Minutes); + } +} diff --git a/src/Marathon.UI/Pages/Settings.razor b/src/Marathon.UI/Pages/Settings.razor index 3619371..5c60e49 100644 --- a/src/Marathon.UI/Pages/Settings.razor +++ b/src/Marathon.UI/Pages/Settings.razor @@ -98,6 +98,9 @@ + + + @@ -197,6 +200,7 @@ LivePollIntervalSeconds = WorkerOpts.CurrentValue.LivePollIntervalSeconds, ResultsPollerEnabled = WorkerOpts.CurrentValue.ResultsPollerEnabled, ResultsPollIntervalSeconds = WorkerOpts.CurrentValue.ResultsPollIntervalSeconds, + AnomalyDetectionEnabled = WorkerOpts.CurrentValue.AnomalyDetectionEnabled, }; _storage = new StorageOptions diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 9354382..050aff5 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -119,6 +119,8 @@ Results poller enabled Disabled until Phase 8. Enable only after match-complete polling is implemented. Results poll interval (sec) + Anomaly detection enabled + Runs the suspension-flip detector on every cycle. Disable to pause analysis without losing collected snapshots. SQLite path Export directory @@ -150,6 +152,37 @@ Suspension flip Confidence + + Anomaly feed + Real-time signal log of suspension-flip events. The detector runs every cycle, computes implied probabilities before and after each market freeze, and surfaces flips ranked by confidence. + Low + Medium + High + Any + Min severity + Sport + Detected from + Detected to + Date range + Mark all read + Detected + Confidence + Kind + Suspension gap + Before suspension + After suspension + Implied prob. + Rate + Suspension duration + Favourite swap + Evidence timeline + Open event + Back to feed + Anomaly not found — it may have been pruned. + No anomalies match the current filters. Loosen the severity threshold or widen the date range. + Total + Unread anomalies + Pre-match schedule Upcoming events with their latest pre-match Win-1 / Draw / Win-2 odds preview. Filter by sport, country, league, or team. diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index e1e9c25..1929416 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -125,6 +125,8 @@ Сборщик результатов включён Отключён до Phase 8. Включите только после реализации опроса match-complete. Интервал сборщика результатов (сек) + Детектор аномалий включён + Запускает детектор разворота после паузы на каждом цикле. Отключение приостанавливает анализ без потери накопленных снимков. Путь к SQLite @@ -163,6 +165,37 @@ Разворот после заморозки Уверенность + + Лента аномалий + Сигнальный журнал «разворотов» в реальном времени. Детектор проходит каждый цикл, считает подразумеваемые вероятности до и после каждой заморозки рынка и ранжирует находки по уверенности. + Низкая + Средняя + Высокая + Любая + Мин. важность + Вид спорта + Обнаружено с + Обнаружено по + Диапазон дат + Отметить прочитанными + Обнаружено + Уверенность + Тип + Длительность паузы + До паузы + После паузы + Подразум. вер. + Кэф + Длительность паузы + Смена фаворита + Хроника свидетельств + Открыть событие + К ленте + Аномалия не найдена — возможно, она была удалена. + Под текущие фильтры аномалии не попадают. Снизьте порог важности или расширьте диапазон дат. + Всего + Непрочитанные аномалии + Расписание до матча Предстоящие события с последним предматчевым превью «1 / X / 2». Фильтр по виду спорта, стране, лиге и команде. diff --git a/src/Marathon.UI/Services/AnomalyBrowsingService.cs b/src/Marathon.UI/Services/AnomalyBrowsingService.cs new file mode 100644 index 0000000..6ece9ed --- /dev/null +++ b/src/Marathon.UI/Services/AnomalyBrowsingService.cs @@ -0,0 +1,253 @@ +using System.Text.Json; +using Marathon.Application.Abstractions; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using DomainEventId = Marathon.Domain.ValueObjects.EventId; + +namespace Marathon.UI.Services; + +/// +/// Repository-backed anomaly browsing service. Loads anomalies + their +/// originating events in a single pass, parses EvidenceJson, and shapes +/// / records for +/// the UI to consume directly. +/// +public sealed class AnomalyBrowsingService : IAnomalyBrowsingService +{ + private readonly IAnomalyRepository _anomalies; + private readonly IEventRepository _events; + + public AnomalyBrowsingService(IAnomalyRepository anomalies, IEventRepository events) + { + _anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies)); + _events = events ?? throw new ArgumentNullException(nameof(events)); + } + + public async Task> ListAsync(AnomalyFilter filter, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(filter); + + var all = await _anomalies.ListAsync(ct).ConfigureAwait(false); + if (all.Count == 0) return Array.Empty(); + + // Resolve event metadata in one pass — distinct EventIds only. + var eventLookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false); + + var items = new List(all.Count); + foreach (var anomaly in all) + { + ct.ThrowIfCancellationRequested(); + if (TryProject(anomaly, eventLookup, out var item)) + { + items.Add(item); + } + } + + // Apply filters in-memory (small list, UI page). + IEnumerable filtered = items; + + if (filter.MinSeverity is { } minSeverity) + { + filtered = filtered.Where(i => AnomalySeverityRules.MeetsThreshold(i.Severity, minSeverity)); + } + + if (filter.SportCodes is { Count: > 0 } sports) + { + filtered = filtered.Where(i => sports.Contains(i.Sport.Value)); + } + + if (filter.From is { } from) + { + filtered = filtered.Where(i => i.DetectedAt >= from); + } + + if (filter.To is { } to) + { + filtered = filtered.Where(i => i.DetectedAt <= to); + } + + return filtered + .OrderByDescending(static i => i.DetectedAt) + .ToList(); + } + + public async Task GetByIdAsync(Guid id, CancellationToken ct) + { + var anomaly = await _anomalies.GetAsync(id, ct).ConfigureAwait(false); + if (anomaly is null) return null; + + var eventLookup = await BuildEventLookupAsync(new[] { anomaly }, ct).ConfigureAwait(false); + if (!TryProject(anomaly, eventLookup, out var item)) return null; + + if (!TryParseEvidence(anomaly.EvidenceJson, out var dto)) return null; + + var pre = ToSnapshot(dto.PreSuspension); + var post = ToSnapshot(dto.PostSuspension); + + return new AnomalyDetailVm(item, pre, post); + } + + public async Task GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct) + { + var all = await _anomalies.ListAsync(ct).ConfigureAwait(false); + var count = 0; + foreach (var anomaly in all) + { + if (anomaly.DetectedAt > since) count++; + } + return count; + } + + public async Task> ListKnownSportCodesAsync(CancellationToken ct) + { + var all = await _anomalies.ListAsync(ct).ConfigureAwait(false); + if (all.Count == 0) return Array.Empty(); + + var lookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false); + return all + .Select(a => lookup.TryGetValue(a.EventId, out var ev) ? ev.Sport.Value : (int?)null) + .Where(static x => x.HasValue) + .Select(static x => x!.Value) + .Distinct() + .OrderBy(static x => x) + .ToList(); + } + + // ---------------- internals ---------------- + + private async Task> BuildEventLookupAsync( + IReadOnlyCollection anomalies, + CancellationToken ct) + { + var distinct = anomalies + .Select(a => a.EventId) + .Distinct() + .ToList(); + + var dict = new Dictionary(distinct.Count); + foreach (var eid in distinct) + { + ct.ThrowIfCancellationRequested(); + var ev = await _events.GetAsync(eid, ct).ConfigureAwait(false); + if (ev is not null) dict[eid] = ev; + } + return dict; + } + + private static bool TryProject( + Anomaly anomaly, + IReadOnlyDictionary events, + out AnomalyListItem item) + { + item = default!; + if (!TryParseEvidence(anomaly.EvidenceJson, out var dto)) return false; + + var severity = AnomalySeverityRules.FromScore(anomaly.Score); + + events.TryGetValue(anomaly.EventId, out var ev); + + var sport = ev?.Sport ?? new SportCode(0); + var country = ev?.CountryCode ?? string.Empty; + var league = ev?.LeagueId ?? string.Empty; + var title = ev is not null + ? $"{ev.Side1Name} vs {ev.Side2Name}" + : anomaly.EventId.Value; + + var preSnap = ToSnapshot(dto.PreSuspension); + var postSnap = ToSnapshot(dto.PostSuspension); + + var twoWay = dto.PreSuspension.PDraw is null && dto.PostSuspension.PDraw is null; + + item = new AnomalyListItem( + anomaly.Id, + anomaly.EventId, + title, + sport, + country, + league, + anomaly.DetectedAt, + anomaly.Score, + severity, + anomaly.Kind, + dto.SuspensionGapSeconds, + preSnap.Rate1, + preSnap.RateDraw, + preSnap.Rate2, + postSnap.Rate1, + postSnap.RateDraw, + postSnap.Rate2, + preSnap.Favourite, + postSnap.Favourite, + twoWay); + + return true; + } + + private static bool TryParseEvidence(string evidenceJson, out EvidenceDto dto) + { + dto = default!; + if (string.IsNullOrWhiteSpace(evidenceJson)) return false; + try + { + var parsed = JsonSerializer.Deserialize(evidenceJson, JsonOptions); + if (parsed?.PreSuspension is null || parsed.PostSuspension is null) return false; + dto = parsed; + return true; + } + catch (JsonException) + { + return false; + } + } + + private static AnomalyEvidenceSnapshot ToSnapshot(EvidenceSnapshotDto dto) + { + var fav = ResolveFavourite(dto.P1, dto.PDraw, dto.P2); + return new AnomalyEvidenceSnapshot( + dto.CapturedAt, + dto.Rate1, + dto.RateDraw, + dto.Rate2, + dto.P1, + dto.PDraw, + dto.P2, + fav); + } + + private static AnomalyFavourite ResolveFavourite(decimal? p1, decimal? pDraw, decimal? p2) + { + // Favourite = side with the highest implied probability (lowest odds). + var best = AnomalyFavourite.None; + decimal bestValue = decimal.MinValue; + + if (p1 is { } v1 && v1 > bestValue) { bestValue = v1; best = AnomalyFavourite.Side1; } + if (pDraw is { } vd && vd > bestValue) { bestValue = vd; best = AnomalyFavourite.Draw; } + if (p2 is { } v2 && v2 > bestValue) { best = AnomalyFavourite.Side2; } + + return best; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private sealed class EvidenceDto + { + public int SuspensionGapSeconds { get; set; } + public EvidenceSnapshotDto PreSuspension { get; set; } = default!; + public EvidenceSnapshotDto PostSuspension { get; set; } = default!; + } + + private sealed class EvidenceSnapshotDto + { + public DateTimeOffset CapturedAt { get; set; } + public decimal? P1 { get; set; } + public decimal? PDraw { get; set; } + public decimal? P2 { get; set; } + public decimal? Rate1 { get; set; } + public decimal? RateDraw { get; set; } + public decimal? Rate2 { get; set; } + } +} diff --git a/src/Marathon.UI/Services/AnomalyBrowsingState.cs b/src/Marathon.UI/Services/AnomalyBrowsingState.cs new file mode 100644 index 0000000..77891f3 --- /dev/null +++ b/src/Marathon.UI/Services/AnomalyBrowsingState.cs @@ -0,0 +1,59 @@ +namespace Marathon.UI.Services; + +/// +/// Singleton (per RCL) anomaly feed state — current filter, last-seen +/// timestamp for the unread-badge, and a cached unread count. +/// Pages produce new instances and call +/// ; the OnChange event re-renders subscribers. +/// +/// +/// LastSeenUtc is held in memory; the host can persist it through the standard +/// settings writer if desired. It seeds to DateTimeOffset.MinValue so +/// the first session shows every anomaly as unread. +/// +public sealed class AnomalyBrowsingState +{ + private AnomalyFilter _filter = new(); + private DateTimeOffset _lastSeenUtc = DateTimeOffset.MinValue; + private int _unreadCount; + + public AnomalyFilter Filter => _filter; + + public DateTimeOffset LastSeenUtc => _lastSeenUtc; + + public int UnreadCount => _unreadCount; + + public event Action? OnChange; + + public void UpdateFilter(AnomalyFilter next) + { + ArgumentNullException.ThrowIfNull(next); + if (Equals(_filter, next)) return; + _filter = next; + OnChange?.Invoke(); + } + + public void MarkAllSeen(DateTimeOffset utcNow) + { + if (_lastSeenUtc == utcNow && _unreadCount == 0) return; + _lastSeenUtc = utcNow; + _unreadCount = 0; + OnChange?.Invoke(); + } + + public void SetUnreadCount(int count) + { + var clamped = count < 0 ? 0 : count; + if (_unreadCount == clamped) return; + _unreadCount = clamped; + OnChange?.Invoke(); + } + + public void SeedLastSeen(DateTimeOffset value) + { + // Used by hosts that persist the last-seen timestamp across restarts. + if (_lastSeenUtc == value) return; + _lastSeenUtc = value; + OnChange?.Invoke(); + } +} diff --git a/src/Marathon.UI/Services/AnomalyViewModels.cs b/src/Marathon.UI/Services/AnomalyViewModels.cs new file mode 100644 index 0000000..a3188f0 --- /dev/null +++ b/src/Marathon.UI/Services/AnomalyViewModels.cs @@ -0,0 +1,105 @@ +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.UI.Services; + +/// +/// Severity bucket derived from . +/// Phase 7 mapping (see backend handoff): +/// Low = [0.30, 0.45), Medium = [0.45, 0.60), High = [0.60, 1.00]. +/// +public enum AnomalySeverity +{ + Low, + Medium, + High, +} + +/// +/// Filter state passed from a page to . +/// All fields optional — empty filter returns the full feed. +/// +public sealed record AnomalyFilter( + AnomalySeverity? MinSeverity = null, + IReadOnlyCollection? SportCodes = null, + DateTimeOffset? From = null, + DateTimeOffset? To = null); + +/// +/// Compact anomaly row used by the feed page. Designed to render without any +/// further repository calls — pre-shaped strings + parsed evidence summary. +/// +public sealed record AnomalyListItem( + Guid Id, + EventId EventId, + string EventTitle, + SportCode Sport, + string CountryCode, + string LeagueId, + DateTimeOffset DetectedAt, + decimal Score, + AnomalySeverity Severity, + AnomalyKind Kind, + int SuspensionGapSeconds, + decimal? PreWin1Rate, + decimal? PreDrawRate, + decimal? PreWin2Rate, + decimal? PostWin1Rate, + decimal? PostDrawRate, + decimal? PostWin2Rate, + AnomalyFavourite PreFavourite, + AnomalyFavourite PostFavourite, + bool IsTwoWay); + +/// +/// Full anomaly aggregate for the detail page. Carries the parsed evidence +/// snapshots plus the originating event metadata for the link-back affordance. +/// +public sealed record AnomalyDetailVm( + AnomalyListItem Item, + AnomalyEvidenceSnapshot Pre, + AnomalyEvidenceSnapshot Post); + +/// +/// Snapshot bracket of the suspension window — pre or post — with raw rates, +/// implied probabilities, and the side that was the favourite at that moment. +/// +public sealed record AnomalyEvidenceSnapshot( + DateTimeOffset CapturedAt, + decimal? Rate1, + decimal? RateDraw, + decimal? Rate2, + decimal? P1, + decimal? PDraw, + decimal? P2, + AnomalyFavourite Favourite); + +/// Side that holds the lowest implied probability in a snapshot. +public enum AnomalyFavourite +{ + Side1, + Draw, + Side2, + None, +} + +/// Helpers for severity bucketing. +public static class AnomalySeverityRules +{ + public const decimal LowThreshold = 0.30m; + public const decimal MediumThreshold = 0.45m; + public const decimal HighThreshold = 0.60m; + + public static AnomalySeverity FromScore(decimal score) => score switch + { + < MediumThreshold => AnomalySeverity.Low, + < HighThreshold => AnomalySeverity.Medium, + _ => AnomalySeverity.High, + }; + + public static bool MeetsThreshold(AnomalySeverity actual, AnomalySeverity? minimum) + { + if (minimum is null) return true; + return (int)actual >= (int)minimum.Value; + } +} diff --git a/src/Marathon.UI/Services/IAnomalyBrowsingService.cs b/src/Marathon.UI/Services/IAnomalyBrowsingService.cs new file mode 100644 index 0000000..3d74285 --- /dev/null +++ b/src/Marathon.UI/Services/IAnomalyBrowsingService.cs @@ -0,0 +1,22 @@ +namespace Marathon.UI.Services; + +/// +/// Read-only browsing facade over the Anomaly + Event repositories. Pages +/// depend on this — never on IAnomalyRepository directly — so view-model +/// shaping (severity buckets, evidence parsing, event metadata join) stays in +/// one place. +/// +public interface IAnomalyBrowsingService +{ + /// List anomalies matching , newest first. + Task> ListAsync(AnomalyFilter filter, CancellationToken ct); + + /// Single anomaly aggregate for the detail page; null when not found. + Task GetByIdAsync(Guid id, CancellationToken ct); + + /// Count of anomalies detected after — drives the nav badge. + Task GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct); + + /// The set of distinct sport codes present in the anomaly feed — used to populate filter chips. + Task> ListKnownSportCodesAsync(CancellationToken ct); +} diff --git a/src/Marathon.UI/Services/UiServicesExtensions.cs b/src/Marathon.UI/Services/UiServicesExtensions.cs index c8a6525..a12864f 100644 --- a/src/Marathon.UI/Services/UiServicesExtensions.cs +++ b/src/Marathon.UI/Services/UiServicesExtensions.cs @@ -46,9 +46,11 @@ public static class UiServicesExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); - // Browsing facade — Scoped so it captures the per-circuit repository scope. + // Browsing facades — Scoped so they capture the per-circuit repository scope. services.AddScoped(); + services.AddScoped(); // Settings writer — file path is host-resolved. services.AddSingleton(_ => new JsonSettingsWriter(settingsLocalPath)); diff --git a/src/Marathon.UI/Services/WorkerOptions.cs b/src/Marathon.UI/Services/WorkerOptions.cs index 5876449..74a2dbe 100644 --- a/src/Marathon.UI/Services/WorkerOptions.cs +++ b/src/Marathon.UI/Services/WorkerOptions.cs @@ -34,4 +34,11 @@ public sealed class WorkerOptions /// Default: 300 s (5 minutes). /// public int ResultsPollIntervalSeconds { get; set; } = 300; + + /// + /// Whether the anomaly-detection poller should run. + /// Default: true — this is the product's primary differentiator + /// (Phase 7) and should be enabled by default. + /// + public bool AnomalyDetectionEnabled { get; set; } = true; } diff --git a/tests/Marathon.UI.Tests/Components/AnomalyCardTests.cs b/tests/Marathon.UI.Tests/Components/AnomalyCardTests.cs new file mode 100644 index 0000000..7cf860f --- /dev/null +++ b/tests/Marathon.UI.Tests/Components/AnomalyCardTests.cs @@ -0,0 +1,84 @@ +using Bunit; +using Marathon.UI.Components; +using Marathon.UI.Services; +using Marathon.UI.Tests.Support; +using Microsoft.AspNetCore.Components; + +namespace Marathon.UI.Tests.Components; + +public sealed class AnomalyCardTests : MarathonTestContext +{ + [Fact] + public void Renders_event_title_severity_pill_and_kind_kicker() + { + var item = FakeAnomalyBrowsingService.MakeItem( + title: "Lakers vs Bulls", + score: 0.72m); + + var cut = RenderComponent(p => p.Add(c => c.Item, item)); + + cut.Markup.Should().Contain("Lakers vs Bulls"); + cut.Find("[data-test=severity-badge]").GetAttribute("class").Should().Contain("m-severity--high"); + cut.Markup.Should().Contain("Anomaly.Kind.SuspensionFlip"); + } + + [Fact] + public void Card_uses_high_severity_left_border_class() + { + var item = FakeAnomalyBrowsingService.MakeItem(score: 0.65m); + var cut = RenderComponent(p => p.Add(c => c.Item, item)); + + var card = cut.Find("[data-test=anomaly-card]"); + card.GetAttribute("class").Should().Contain("m-anomaly-card--high"); + } + + [Fact] + public void Card_uses_low_severity_left_border_class() + { + var item = FakeAnomalyBrowsingService.MakeItem(score: 0.32m); + var cut = RenderComponent(p => p.Add(c => c.Item, item)); + + var card = cut.Find("[data-test=anomaly-card]"); + card.GetAttribute("class").Should().Contain("m-anomaly-card--low"); + } + + [Fact] + public void Click_invokes_callback_with_item() + { + var item = FakeAnomalyBrowsingService.MakeItem(); + AnomalyListItem? clicked = null; + var cut = RenderComponent(p => p + .Add(c => c.Item, item) + .Add(c => c.OnClick, EventCallback.Factory.Create(this, x => clicked = x))); + + cut.Find("[data-test=anomaly-card]").Click(); + + clicked.Should().NotBeNull(); + clicked!.Id.Should().Be(item.Id); + } + + [Fact] + public void Two_way_card_omits_draw_strip_cell() + { + var item = FakeAnomalyBrowsingService.MakeItem(isTwoWay: true); + var cut = RenderComponent(p => p.Add(c => c.Item, item)); + + // Strip should mention Win1 + Win2 only — not Draw. + cut.Markup.Should().Contain("Detail.Chart.Win1"); + cut.Markup.Should().Contain("Detail.Chart.Win2"); + cut.Markup.Should().NotContain("Detail.Chart.Draw"); + } + + [Fact] + public void Three_way_card_renders_draw_strip_cell() + { + var item = FakeAnomalyBrowsingService.MakeItem( + isTwoWay: false, + preDraw: 3.30m, + postDraw: 3.20m); + + var cut = RenderComponent(p => p.Add(c => c.Item, item)); + + cut.Markup.Should().Contain("Detail.Chart.Draw"); + } +} diff --git a/tests/Marathon.UI.Tests/Components/AnomalyEvidenceTests.cs b/tests/Marathon.UI.Tests/Components/AnomalyEvidenceTests.cs new file mode 100644 index 0000000..9feef58 --- /dev/null +++ b/tests/Marathon.UI.Tests/Components/AnomalyEvidenceTests.cs @@ -0,0 +1,115 @@ +using Bunit; +using Marathon.UI.Components; +using Marathon.UI.Services; +using Marathon.UI.Tests.Support; + +namespace Marathon.UI.Tests.Components; + +public sealed class AnomalyEvidenceTests : MarathonTestContext +{ + [Fact] + public void Renders_two_columns_with_pre_and_post_kickers() + { + var pre = FakeAnomalyBrowsingService.MakeSnapshot(); + var post = FakeAnomalyBrowsingService.MakeSnapshot( + rate1: 4.00m, + rate2: 1.30m, + p1: 0.245m, + p2: 0.755m, + favourite: AnomalyFavourite.Side2); + + var cut = RenderComponent(p => p + .Add(e => e.Pre, pre) + .Add(e => e.Post, post) + .Add(e => e.SuspensionGapSeconds, 90) + .Add(e => e.IsTwoWay, true)); + + cut.Markup.Should().Contain("Anomaly.Evidence.Pre"); + cut.Markup.Should().Contain("Anomaly.Evidence.Post"); + cut.FindAll(".m-evidence__col").Count.Should().Be(2); + } + + [Fact] + public void Highlights_favourite_swap_when_sides_differ() + { + var pre = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side1); + var post = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side2); + + var cut = RenderComponent(p => p + .Add(e => e.Pre, pre) + .Add(e => e.Post, post) + .Add(e => e.SuspensionGapSeconds, 90) + .Add(e => e.IsTwoWay, true)); + + cut.FindAll("[data-test=favourite-swap]").Should().NotBeEmpty(); + cut.Markup.Should().Contain("Anomaly.Evidence.FavouriteSwap"); + } + + [Fact] + public void Hides_favourite_swap_callout_when_favourite_unchanged() + { + var pre = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side1); + var post = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side1); + + var cut = RenderComponent(p => p + .Add(e => e.Pre, pre) + .Add(e => e.Post, post) + .Add(e => e.SuspensionGapSeconds, 90) + .Add(e => e.IsTwoWay, true)); + + cut.FindAll("[data-test=favourite-swap]").Should().BeEmpty(); + } + + [Fact] + public void Two_way_market_skips_draw_row() + { + var pre = FakeAnomalyBrowsingService.MakeSnapshot(rateDraw: null, pDraw: null); + var post = FakeAnomalyBrowsingService.MakeSnapshot(rateDraw: null, pDraw: null, + favourite: AnomalyFavourite.Side2); + + var cut = RenderComponent(p => p + .Add(e => e.Pre, pre) + .Add(e => e.Post, post) + .Add(e => e.SuspensionGapSeconds, 60) + .Add(e => e.IsTwoWay, true)); + + // 2-way → 4 rows total (2 cols × 2 sides), not 6. + cut.FindAll(".m-evidence__row").Count.Should().Be(4); + cut.Markup.Should().NotContain("Detail.Chart.Draw"); + } + + [Fact] + public void Three_way_market_includes_draw_row_in_each_column() + { + var pre = FakeAnomalyBrowsingService.MakeSnapshot(rateDraw: 3.30m, pDraw: 0.30m); + var post = FakeAnomalyBrowsingService.MakeSnapshot(rateDraw: 3.20m, pDraw: 0.31m, + favourite: AnomalyFavourite.Draw); + + var cut = RenderComponent(p => p + .Add(e => e.Pre, pre) + .Add(e => e.Post, post) + .Add(e => e.SuspensionGapSeconds, 120) + .Add(e => e.IsTwoWay, false)); + + // 3-way → 6 rows total (2 cols × 3 sides). + cut.FindAll(".m-evidence__row").Count.Should().Be(6); + cut.Markup.Should().Contain("Detail.Chart.Draw"); + } + + [Fact] + public void Renders_suspension_duration_label() + { + var pre = FakeAnomalyBrowsingService.MakeSnapshot(); + var post = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side2); + + var cut = RenderComponent(p => p + .Add(e => e.Pre, pre) + .Add(e => e.Post, post) + .Add(e => e.SuspensionGapSeconds, 134) + .Add(e => e.IsTwoWay, true)); + + cut.Markup.Should().Contain("Anomaly.Evidence.SuspensionDuration"); + // 134s -> "2m 14s" + cut.Markup.Should().Contain("2m 14s"); + } +} diff --git a/tests/Marathon.UI.Tests/Components/SeverityBadgeTests.cs b/tests/Marathon.UI.Tests/Components/SeverityBadgeTests.cs new file mode 100644 index 0000000..d0cd8dc --- /dev/null +++ b/tests/Marathon.UI.Tests/Components/SeverityBadgeTests.cs @@ -0,0 +1,69 @@ +using Bunit; +using Marathon.UI.Components; +using Marathon.UI.Services; +using Marathon.UI.Tests.Support; + +namespace Marathon.UI.Tests.Components; + +public sealed class SeverityBadgeTests : MarathonTestContext +{ + [Theory] + [InlineData(0.30, AnomalySeverity.Low)] + [InlineData(0.44, AnomalySeverity.Low)] + [InlineData(0.45, AnomalySeverity.Medium)] + [InlineData(0.59, AnomalySeverity.Medium)] + [InlineData(0.60, AnomalySeverity.High)] + [InlineData(0.95, AnomalySeverity.High)] + public void From_score_buckets_correctly(double score, AnomalySeverity expected) + { + AnomalySeverityRules.FromScore((decimal)score).Should().Be(expected); + } + + [Fact] + public void Renders_high_severity_pill_with_anomaly_class() + { + var cut = RenderComponent(p => p + .Add(b => b.Severity, AnomalySeverity.High) + .Add(b => b.Score, 0.72m)); + + var pill = cut.Find("[data-test=severity-badge]"); + pill.GetAttribute("class").Should().Contain("m-severity--high"); + cut.Markup.Should().Contain("Anomaly.Severity.High"); + cut.Markup.Should().Contain("0.72"); + } + + [Fact] + public void Renders_medium_severity_pill_with_amber_accent_class() + { + var cut = RenderComponent(p => p + .Add(b => b.Severity, AnomalySeverity.Medium) + .Add(b => b.Score, 0.50m)); + + var pill = cut.Find("[data-test=severity-badge]"); + pill.GetAttribute("class").Should().Contain("m-severity--medium"); + cut.Markup.Should().Contain("Anomaly.Severity.Medium"); + } + + [Fact] + public void Renders_low_severity_pill_with_neutral_class() + { + var cut = RenderComponent(p => p + .Add(b => b.Severity, AnomalySeverity.Low) + .Add(b => b.Score, 0.32m)); + + var pill = cut.Find("[data-test=severity-badge]"); + pill.GetAttribute("class").Should().Contain("m-severity--low"); + cut.Markup.Should().Contain("Anomaly.Severity.Low"); + } + + [Fact] + public void Hides_score_when_disabled() + { + var cut = RenderComponent(p => p + .Add(b => b.Severity, AnomalySeverity.High) + .Add(b => b.Score, 0.81m) + .Add(b => b.ShowScore, false)); + + cut.Markup.Should().NotContain("0.81"); + } +} diff --git a/tests/Marathon.UI.Tests/Pages/Anomalies/AnomalyDetailTests.cs b/tests/Marathon.UI.Tests/Pages/Anomalies/AnomalyDetailTests.cs new file mode 100644 index 0000000..8a3a08e --- /dev/null +++ b/tests/Marathon.UI.Tests/Pages/Anomalies/AnomalyDetailTests.cs @@ -0,0 +1,78 @@ +using Bunit; +using Marathon.UI.Pages.Anomalies; +using Marathon.UI.Services; +using Marathon.UI.Tests.Support; + +namespace Marathon.UI.Tests.Pages.Anomalies; + +public sealed class AnomalyDetailTests : MarathonTestContext +{ + [Fact] + public void Renders_not_found_when_detail_is_null() + { + AnomalyBrowsing.Detail = null; + var cut = RenderComponent(p => p.Add(d => d.Id, Guid.NewGuid())); + + cut.WaitForAssertion(() => + cut.Markup.Should().Contain("Anomaly.Detail.NotFound")); + } + + [Fact] + public void Renders_event_title_severity_pill_and_evidence_panel() + { + var item = FakeAnomalyBrowsingService.MakeItem(title: "Lakers vs Bulls", score: 0.72m); + var pre = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side1); + var post = FakeAnomalyBrowsingService.MakeSnapshot( + rate1: 4.00m, rate2: 1.30m, + p1: 0.245m, p2: 0.755m, + favourite: AnomalyFavourite.Side2); + + AnomalyBrowsing.Detail = new AnomalyDetailVm(item, pre, post); + + var cut = RenderComponent(p => p.Add(d => d.Id, item.Id)); + + cut.WaitForAssertion(() => + { + cut.Markup.Should().Contain("Lakers vs Bulls"); + cut.FindAll("[data-test=severity-badge]").Should().NotBeEmpty(); + cut.Markup.Should().Contain("Anomaly.Detail.EvidenceTitle"); + cut.FindAll("[data-test=anomaly-evidence]").Should().NotBeEmpty(); + }); + } + + [Fact] + public void Renders_link_back_to_event_button() + { + var item = FakeAnomalyBrowsingService.MakeItem(); + var pre = FakeAnomalyBrowsingService.MakeSnapshot(); + var post = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side2); + + AnomalyBrowsing.Detail = new AnomalyDetailVm(item, pre, post); + + var cut = RenderComponent(p => p.Add(d => d.Id, item.Id)); + + cut.WaitForAssertion(() => + { + cut.FindAll("[data-test=link-back-to-event]").Should().NotBeEmpty(); + cut.Markup.Should().Contain("Anomaly.Detail.LinkBackToEvent"); + }); + } + + [Fact] + public void Renders_suspension_duration_in_header_summary() + { + var item = FakeAnomalyBrowsingService.MakeItem(gapSeconds: 134); + var pre = FakeAnomalyBrowsingService.MakeSnapshot(); + var post = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side2); + + AnomalyBrowsing.Detail = new AnomalyDetailVm(item, pre, post); + + var cut = RenderComponent(p => p.Add(d => d.Id, item.Id)); + + cut.WaitForAssertion(() => + { + var node = cut.Find("[data-test=suspension-duration]"); + node.TextContent.Should().Contain("2m 14s"); + }); + } +} diff --git a/tests/Marathon.UI.Tests/Pages/Anomalies/AnomalyFeedTests.cs b/tests/Marathon.UI.Tests/Pages/Anomalies/AnomalyFeedTests.cs new file mode 100644 index 0000000..7d0770e --- /dev/null +++ b/tests/Marathon.UI.Tests/Pages/Anomalies/AnomalyFeedTests.cs @@ -0,0 +1,113 @@ +using Bunit; +using Marathon.UI.Pages.Anomalies; +using Marathon.UI.Services; +using Marathon.UI.Tests.Support; + +namespace Marathon.UI.Tests.Pages.Anomalies; + +public sealed class AnomalyFeedTests : MarathonTestContext +{ + [Fact] + public void Renders_seeded_anomaly_cards() + { + AnomalyBrowsing.SportCodes.AddRange(new[] { 6, 11 }); + AnomalyBrowsing.Items.AddRange(new[] + { + FakeAnomalyBrowsingService.MakeItem(title: "Lakers vs Bulls", sport: 6, score: 0.65m), + FakeAnomalyBrowsingService.MakeItem(title: "Arsenal vs Chelsea", sport: 11, score: 0.40m), + }); + + var cut = RenderComponent(); + + cut.WaitForAssertion(() => + { + cut.FindAll("[data-test=anomaly-card]").Count.Should().Be(2); + cut.Markup.Should().Contain("Lakers vs Bulls"); + cut.Markup.Should().Contain("Arsenal vs Chelsea"); + }); + } + + [Fact] + public void Renders_empty_state_when_no_anomalies_match_filter() + { + var cut = RenderComponent(); + cut.WaitForAssertion(() => + { + cut.FindAll("[data-test=anomaly-empty]").Should().NotBeEmpty(); + cut.Markup.Should().Contain("Anomaly.Empty.NoneInRange"); + }); + } + + [Fact] + public void Severity_chip_filter_narrows_the_list() + { + AnomalyBrowsing.Items.AddRange(new[] + { + FakeAnomalyBrowsingService.MakeItem(title: "High event", score: 0.70m), + FakeAnomalyBrowsingService.MakeItem(title: "Low event", score: 0.32m), + }); + + var cut = RenderComponent(); + cut.WaitForAssertion(() => + cut.FindAll("[data-test=anomaly-card]").Count.Should().Be(2)); + + // Click the High severity chip. + var highChip = cut.FindAll("[data-test=severity-chip]") + .First(e => e.GetAttribute("data-severity") == "High"); + highChip.Click(); + + cut.WaitForAssertion(() => + { + var cards = cut.FindAll("[data-test=anomaly-card]"); + cards.Count.Should().Be(1); + cut.Markup.Should().Contain("High event"); + cut.Markup.Should().NotContain("Low event"); + AnomalyBrowsing.LastFilter.Should().NotBeNull(); + AnomalyBrowsing.LastFilter!.MinSeverity.Should().Be(AnomalySeverity.High); + }); + } + + [Fact] + public void Sport_chip_filter_narrows_the_list() + { + AnomalyBrowsing.SportCodes.AddRange(new[] { 6, 11 }); + AnomalyBrowsing.Items.AddRange(new[] + { + FakeAnomalyBrowsingService.MakeItem(title: "Lakers vs Bulls", sport: 6, score: 0.55m), + FakeAnomalyBrowsingService.MakeItem(title: "Arsenal vs Chelsea", sport: 11, score: 0.55m), + }); + + var cut = RenderComponent(); + cut.WaitForAssertion(() => + cut.FindAll("[data-test=anomaly-card]").Count.Should().Be(2)); + + // Pick the basketball chip (sport=6). + var sportChips = cut.FindAll("[data-test=sport-chip]"); + sportChips.Should().NotBeEmpty(); + sportChips.First().Click(); + + cut.WaitForAssertion(() => + { + AnomalyBrowsing.LastFilter.Should().NotBeNull(); + AnomalyBrowsing.LastFilter!.SportCodes.Should().NotBeNull(); + AnomalyBrowsing.LastFilter!.SportCodes!.Should().HaveCount(1); + }); + } + + [Fact] + public void Mark_read_button_clears_unread_count_in_state() + { + AnomalyState.SetUnreadCount(5); + AnomalyBrowsing.UnreadCount = 0; + AnomalyBrowsing.Items.Add(FakeAnomalyBrowsingService.MakeItem()); + + var cut = RenderComponent(); + cut.WaitForAssertion(() => + cut.FindAll("[data-test=anomaly-card]").Count.Should().Be(1)); + + cut.Find("[data-test=mark-read]").Click(); + + AnomalyState.UnreadCount.Should().Be(0); + AnomalyState.LastSeenUtc.Should().BeAfter(DateTimeOffset.MinValue); + } +} diff --git a/tests/Marathon.UI.Tests/Support/FakeAnomalyBrowsingService.cs b/tests/Marathon.UI.Tests/Support/FakeAnomalyBrowsingService.cs new file mode 100644 index 0000000..820b95a --- /dev/null +++ b/tests/Marathon.UI.Tests/Support/FakeAnomalyBrowsingService.cs @@ -0,0 +1,110 @@ +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using Marathon.UI.Services; + +namespace Marathon.UI.Tests.Support; + +/// +/// In-memory for bUnit tests. +/// Seed via / . +/// +public sealed class FakeAnomalyBrowsingService : IAnomalyBrowsingService +{ + public List Items { get; } = new(); + public AnomalyDetailVm? Detail { get; set; } + public List SportCodes { get; } = new(); + public int UnreadCount { get; set; } + public int ListCallCount { get; private set; } + public AnomalyFilter? LastFilter { get; private set; } + + public Task> ListAsync(AnomalyFilter filter, CancellationToken ct) + { + ListCallCount++; + LastFilter = filter; + IEnumerable 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>( + q.OrderByDescending(static i => i.DetectedAt).ToList()); + } + + public Task GetByIdAsync(Guid id, CancellationToken ct) + => Task.FromResult(Detail); + + public Task GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct) + => Task.FromResult(UnreadCount); + + public Task> ListKnownSportCodesAsync(CancellationToken ct) + => Task.FromResult>(SportCodes); + + /// Builds an for tests with sane defaults. + 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); +} diff --git a/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs b/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs index 066427a..af2974c 100644 --- a/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs +++ b/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs @@ -21,6 +21,8 @@ public abstract class MarathonTestContext : TestContext protected LocaleState Locale { get; } = new(); protected EventBrowsingState BrowsingState { get; } = new(); protected FakeEventBrowsingService Browsing { get; } = new(); + protected AnomalyBrowsingState AnomalyState { get; } = new(); + protected FakeAnomalyBrowsingService AnomalyBrowsing { get; } = new(); protected MarathonTestContext() { @@ -30,6 +32,8 @@ public abstract class MarathonTestContext : TestContext Services.AddSingleton(Locale); Services.AddSingleton(BrowsingState); Services.AddSingleton(Browsing); + Services.AddSingleton(AnomalyState); + Services.AddSingleton(AnomalyBrowsing); Services.AddSingleton(typeof(IStringLocalizer<>), typeof(TestLocalizer<>)); Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));