feat(phase-7-frontend): anomaly feed UI + nav badge + Settings toggle (+31 bUnit tests)
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.
This commit is contained in:
@@ -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<EvidenceDto>(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`.
|
||||
|
||||
@@ -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<WorkerOptions>` 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
|
||||
`<Surface>.<Element>` 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;
|
||||
|
||||
@@ -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 | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
|
||||
@@ -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 `<Surface>.<Element>` convention from Phase 5/6.
|
||||
|
||||
@@ -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<SharedResource> L
|
||||
|
||||
<article class="m-anomaly-card m-anomaly-card--@_severityClass m-rise"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
data-test="anomaly-card"
|
||||
data-anomaly-id="@Item.Id"
|
||||
@onclick="HandleClick"
|
||||
@onkeydown="HandleKey">
|
||||
<header class="m-anomaly-card__head">
|
||||
<div class="m-anomaly-card__lockup">
|
||||
<SportIcon Code="@Item.Sport.Value" Label="@SportLabel(Item.Sport.Value)" ClassName="m-anomaly-card__sport" />
|
||||
<div class="m-anomaly-card__title-block">
|
||||
<span class="m-kicker">@KindLabel(Item.Kind) · @Item.CountryCode · @Item.LeagueId</span>
|
||||
<h3 class="m-anomaly-card__title">@Item.EventTitle</h3>
|
||||
</div>
|
||||
</div>
|
||||
<SeverityBadge Severity="Item.Severity" Score="Item.Score" />
|
||||
</header>
|
||||
|
||||
<div class="m-anomaly-card__strip" aria-hidden="true">
|
||||
@RenderRateCell(L["Detail.Chart.Win1"], Item.PreWin1Rate, Item.PostWin1Rate)
|
||||
@if (!Item.IsTwoWay)
|
||||
{
|
||||
@RenderRateCell(L["Detail.Chart.Draw"], Item.PreDrawRate, Item.PostDrawRate)
|
||||
}
|
||||
@RenderRateCell(L["Detail.Chart.Win2"], Item.PreWin2Rate, Item.PostWin2Rate)
|
||||
</div>
|
||||
|
||||
<footer class="m-anomaly-card__foot">
|
||||
<span class="m-mono m-anomaly-card__time">
|
||||
<span class="m-anomaly-card__time-label">@L["Anomaly.Card.DetectedAt"]</span>
|
||||
<time datetime="@Item.DetectedAt.ToString("o")" title="@Item.DetectedAt.ToString("dd MMM yyyy HH:mm:ss")">
|
||||
@FormatRelative(Item.DetectedAt)
|
||||
</time>
|
||||
</span>
|
||||
<span class="m-mono m-anomaly-card__gap">
|
||||
@L["Anomaly.Card.GapSeconds"] · @FormatGap(Item.SuspensionGapSeconds)
|
||||
</span>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.m-anomaly-card {
|
||||
display: grid;
|
||||
gap: var(--m-space-3);
|
||||
padding: var(--m-space-4) var(--m-space-5);
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
border-left: 3px solid var(--m-c-rule);
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.m-anomaly-card:hover {
|
||||
background: var(--m-c-paper-2);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.m-anomaly-card:focus-visible {
|
||||
outline: 2px solid var(--m-c-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.m-anomaly-card--high { border-left-color: var(--m-c-anomaly); }
|
||||
.m-anomaly-card--medium { border-left-color: var(--m-c-accent); }
|
||||
.m-anomaly-card--low { border-left-color: var(--m-c-ink-soft); }
|
||||
|
||||
.m-anomaly-card__head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--m-space-3);
|
||||
align-items: start;
|
||||
}
|
||||
.m-anomaly-card__lockup {
|
||||
display: flex;
|
||||
gap: var(--m-space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.m-anomaly-card__sport {
|
||||
--m-sport-size: 22px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.m-anomaly-card__title-block { display: grid; gap: 4px; min-width: 0; }
|
||||
.m-anomaly-card__title {
|
||||
margin: 0;
|
||||
font-family: var(--m-font-display);
|
||||
font-weight: 400;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.25;
|
||||
color: var(--m-c-ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m-anomaly-card__strip {
|
||||
display: flex;
|
||||
gap: var(--m-space-3);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--m-space-3);
|
||||
background: var(--m-c-paper-2);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
.m-anomaly-card__rate {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto auto;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
font-family: var(--m-font-mono);
|
||||
}
|
||||
.m-anomaly-card__rate-label {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-anomaly-card__rate-pre {
|
||||
color: var(--m-c-ink-soft);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.m-anomaly-card__rate-arrow { color: var(--m-c-accent); font-size: 0.875rem; }
|
||||
.m-anomaly-card__rate-post {
|
||||
color: var(--m-c-ink);
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.m-anomaly-card--high .m-anomaly-card__rate-post {
|
||||
color: var(--m-c-anomaly);
|
||||
}
|
||||
|
||||
.m-anomaly-card__foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--m-space-3);
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--m-c-ink-soft);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.m-anomaly-card__time-label { margin-right: 6px; opacity: 0.7; }
|
||||
.m-anomaly-card__time time { color: var(--m-c-ink); font-weight: 500; }
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public AnomalyListItem Item { get; set; } = default!;
|
||||
[Parameter] public EventCallback<AnomalyListItem> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<SharedResource> L
|
||||
|
||||
<div class="m-evidence" data-test="anomaly-evidence">
|
||||
<div class="m-evidence__summary">
|
||||
<div class="m-evidence__gap">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
|
||||
@L["Anomaly.Evidence.SuspensionDuration"]
|
||||
</span>
|
||||
<span class="m-evidence__gap-value m-mono">@FormatGap(SuspensionGapSeconds)</span>
|
||||
</div>
|
||||
@if (Pre.Favourite != Post.Favourite && Pre.Favourite != AnomalyFavourite.None && Post.Favourite != AnomalyFavourite.None)
|
||||
{
|
||||
<div class="m-evidence__swap" data-test="favourite-swap">
|
||||
<span class="m-anomaly__pulse" style="background: var(--m-c-anomaly);"></span>
|
||||
<span class="m-evidence__swap-text">
|
||||
@L["Anomaly.Evidence.FavouriteSwap"] ·
|
||||
<strong>@FavLabel(Pre.Favourite) → @FavLabel(Post.Favourite)</strong>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="m-evidence__columns">
|
||||
<article class="m-evidence__col">
|
||||
<header class="m-evidence__col-head">
|
||||
<span class="m-kicker">@L["Anomaly.Evidence.Pre"]</span>
|
||||
<time class="m-mono" datetime="@Pre.CapturedAt.ToString("o")">
|
||||
@Pre.CapturedAt.ToString("dd MMM HH:mm:ss")
|
||||
</time>
|
||||
</header>
|
||||
@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))
|
||||
</article>
|
||||
|
||||
<div class="m-evidence__arrow" aria-hidden="true">→</div>
|
||||
|
||||
<article class="m-evidence__col m-evidence__col--post">
|
||||
<header class="m-evidence__col-head">
|
||||
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
|
||||
@L["Anomaly.Evidence.Post"]
|
||||
</span>
|
||||
<time class="m-mono" datetime="@Post.CapturedAt.ToString("o")">
|
||||
@Post.CapturedAt.ToString("dd MMM HH:mm:ss")
|
||||
</time>
|
||||
</header>
|
||||
@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))
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.m-evidence {
|
||||
display: grid;
|
||||
gap: var(--m-space-4);
|
||||
}
|
||||
.m-evidence__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--m-space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.m-evidence__gap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.m-evidence__gap-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--m-c-ink);
|
||||
}
|
||||
.m-evidence__swap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: color-mix(in srgb, var(--m-c-anomaly) 8%, transparent);
|
||||
border: 1px solid var(--m-c-anomaly);
|
||||
color: var(--m-c-anomaly);
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.m-evidence__swap-text strong { font-weight: 600; }
|
||||
|
||||
.m-evidence__columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: var(--m-space-4);
|
||||
align-items: stretch;
|
||||
}
|
||||
@@media (max-width: 720px) {
|
||||
.m-evidence__columns { grid-template-columns: 1fr; }
|
||||
.m-evidence__arrow { display: none; }
|
||||
}
|
||||
.m-evidence__col {
|
||||
display: grid;
|
||||
gap: var(--m-space-3);
|
||||
padding: var(--m-space-4);
|
||||
background: var(--m-c-paper-2);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
.m-evidence__col--post {
|
||||
border-left: 3px solid var(--m-c-anomaly);
|
||||
}
|
||||
.m-evidence__col-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--m-space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.m-evidence__col-head time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-evidence__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--m-font-display);
|
||||
font-size: 1.75rem;
|
||||
color: var(--m-c-accent);
|
||||
}
|
||||
.m-evidence__row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px minmax(0, 1fr) 56px;
|
||||
gap: var(--m-space-3);
|
||||
align-items: center;
|
||||
}
|
||||
.m-evidence__row-label {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-evidence__row.is-favourite .m-evidence__row-label {
|
||||
color: var(--m-c-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.m-evidence__bar {
|
||||
height: 8px;
|
||||
background: var(--m-c-rule);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.m-evidence__bar-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: var(--m-c-ink-soft);
|
||||
transition: width 240ms ease;
|
||||
}
|
||||
.m-evidence__col--post .m-evidence__bar-fill { background: var(--m-c-anomaly); }
|
||||
.m-evidence__row.is-favourite .m-evidence__bar-fill { background: var(--m-c-accent); }
|
||||
.m-evidence__col--post .m-evidence__row.is-favourite .m-evidence__bar-fill {
|
||||
background: var(--m-c-anomaly);
|
||||
}
|
||||
.m-evidence__rate {
|
||||
text-align: right;
|
||||
font-family: var(--m-font-mono);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
@implements IDisposable
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject AnomalyBrowsingState AnomalyState
|
||||
|
||||
<nav class="m-nav" aria-label="primary">
|
||||
<div style="padding: var(--m-space-5) var(--m-space-4) var(--m-space-3); border-bottom: 1px solid rgba(231,229,228,0.10);">
|
||||
@@ -26,6 +28,12 @@
|
||||
<NavLink class="m-nav__link" href="anomalies">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Warning" Size="Size.Small" />
|
||||
<span>@L["Nav.Anomalies"]</span>
|
||||
@if (AnomalyState.UnreadCount > 0)
|
||||
{
|
||||
<span class="m-nav__badge" data-test="anomaly-badge" aria-label="@L["Anomaly.Nav.UnreadAria"]">
|
||||
@AnomalyState.UnreadCount
|
||||
</span>
|
||||
}
|
||||
</NavLink>
|
||||
<NavLink class="m-nav__link" href="results">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
|
||||
@@ -38,3 +46,39 @@
|
||||
<span>@L["Nav.Settings"]</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.m-nav__badge {
|
||||
margin-left: auto;
|
||||
min-width: 18px;
|
||||
padding: 0 6px;
|
||||
background: var(--m-c-anomaly);
|
||||
color: #ffffff;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 18px;
|
||||
height: 18px;
|
||||
text-align: center;
|
||||
border-radius: var(--m-radius-xs);
|
||||
animation: m-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@@media (prefers-reduced-motion: reduce) {
|
||||
.m-nav__badge { animation: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
AnomalyState.OnChange += OnAnomalyStateChanged;
|
||||
}
|
||||
|
||||
private void OnAnomalyStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AnomalyState.OnChange -= OnAnomalyStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SharedResource> L
|
||||
|
||||
<span class="m-severity m-severity--@_classKey @AdditionalClass" data-severity="@Severity" data-test="severity-badge">
|
||||
@if (ShowDot)
|
||||
{
|
||||
<span class="m-severity__dot" aria-hidden="true"></span>
|
||||
}
|
||||
<span class="m-severity__label">@Label</span>
|
||||
@if (ShowScore && Score is { } s)
|
||||
{
|
||||
<span class="m-severity__score m-mono" aria-hidden="true">@s.ToString("0.00")</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.m-severity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
border-radius: var(--m-radius-xs);
|
||||
border: 1px solid currentColor;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-severity__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.m-severity__score {
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
opacity: 0.78;
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.m-severity--low {
|
||||
color: var(--m-c-ink-soft);
|
||||
background: color-mix(in srgb, var(--m-c-ink-soft) 8%, transparent);
|
||||
}
|
||||
.m-severity--medium {
|
||||
color: var(--m-c-accent);
|
||||
background: color-mix(in srgb, var(--m-c-accent) 12%, transparent);
|
||||
}
|
||||
.m-severity--high {
|
||||
color: var(--m-c-anomaly);
|
||||
background: color-mix(in srgb, var(--m-c-anomaly) 12%, transparent);
|
||||
}
|
||||
.m-severity--high .m-severity__dot {
|
||||
animation: m-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@@media (prefers-reduced-motion: reduce) {
|
||||
.m-severity--high .m-severity__dot { animation: none; opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@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"],
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@page "/anomalies"
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.Anomalies"]</PageTitle>
|
||||
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Anomalies"]" />
|
||||
@@ -0,0 +1,304 @@
|
||||
@page "/anomalies"
|
||||
@using Marathon.UI.Components
|
||||
@implements IDisposable
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IAnomalyBrowsingService Anomalies
|
||||
@inject AnomalyBrowsingState State
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Anomaly.Title"]</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" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
|
||||
@L["Nav.Section.Analysis"]
|
||||
</span>
|
||||
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Anomaly.Title"]</h1>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@L["Anomaly.Lede"]</p>
|
||||
|
||||
<dl class="m-anomaly-feed__stats" aria-label="@L["Anomaly.Title"]">
|
||||
<div class="m-anomaly-feed__stat">
|
||||
<dt>@L["Anomaly.Stat.Total"]</dt>
|
||||
<dd class="m-mono">@_items.Count</dd>
|
||||
</div>
|
||||
<div class="m-anomaly-feed__stat m-anomaly-feed__stat--high">
|
||||
<dt>@L["Anomaly.Severity.High"]</dt>
|
||||
<dd class="m-mono">@_items.Count(i => i.Severity == AnomalySeverity.High)</dd>
|
||||
</div>
|
||||
<div class="m-anomaly-feed__stat m-anomaly-feed__stat--medium">
|
||||
<dt>@L["Anomaly.Severity.Medium"]</dt>
|
||||
<dd class="m-mono">@_items.Count(i => i.Severity == AnomalySeverity.Medium)</dd>
|
||||
</div>
|
||||
<div class="m-anomaly-feed__stat">
|
||||
<dt>@L["Anomaly.Severity.Low"]</dt>
|
||||
<dd class="m-mono">@_items.Count(i => i.Severity == AnomalySeverity.Low)</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<div class="m-list-toolbar m-rise m-rise-2" role="toolbar" aria-label="@L["PreMatch.Filter.Toolbar"]">
|
||||
<div class="m-list-toolbar__row m-list-toolbar__chips">
|
||||
<span class="m-list-toolbar__label">@L["Anomaly.Filter.Severity"]</span>
|
||||
@foreach (var severity in _severityOptions)
|
||||
{
|
||||
var current = severity;
|
||||
var active = _filter.MinSeverity == current;
|
||||
<button type="button"
|
||||
class="m-chip @(active ? "is-active" : null)"
|
||||
aria-pressed="@active"
|
||||
data-test="severity-chip"
|
||||
data-severity="@current"
|
||||
@onclick="() => ToggleSeverity(current)">
|
||||
@SeverityLabel(current)
|
||||
</button>
|
||||
}
|
||||
<button type="button"
|
||||
class="m-chip @(_filter.MinSeverity is null ? "is-active" : null)"
|
||||
aria-pressed="@(_filter.MinSeverity is null)"
|
||||
data-test="severity-chip-any"
|
||||
@onclick="ClearSeverity">
|
||||
@L["Anomaly.Filter.AnySeverity"]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_availableSports.Count > 0)
|
||||
{
|
||||
<div class="m-list-toolbar__row m-list-toolbar__chips">
|
||||
<span class="m-list-toolbar__label">@L["Anomaly.Filter.Sport"]</span>
|
||||
@foreach (var sportCode in _availableSports)
|
||||
{
|
||||
var localCode = sportCode;
|
||||
var active = _filter.SportCodes is { Count: > 0 } sc && sc.Contains(localCode);
|
||||
var sportLabel = SportLabel(localCode);
|
||||
<button type="button"
|
||||
class="m-chip @(active ? "is-active" : null)"
|
||||
aria-pressed="@active"
|
||||
data-test="sport-chip"
|
||||
@onclick="() => ToggleSport(localCode)">
|
||||
<SportIcon Code="@localCode" Label="@sportLabel" ClassName="m-chip__icon" />
|
||||
<span>@sportLabel</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="m-list-toolbar__row">
|
||||
<div class="m-list-toolbar__group">
|
||||
<label class="m-list-toolbar__label">@L["Anomaly.Filter.From"]</label>
|
||||
<input class="m-input" type="date" value="@FormatDate(_filter.From)"
|
||||
aria-label="@L["Anomaly.Filter.From"]"
|
||||
@onchange="OnFromChanged" />
|
||||
</div>
|
||||
<div class="m-list-toolbar__group">
|
||||
<label class="m-list-toolbar__label">@L["Anomaly.Filter.To"]</label>
|
||||
<input class="m-input" type="date" value="@FormatDate(_filter.To)"
|
||||
aria-label="@L["Anomaly.Filter.To"]"
|
||||
@onchange="OnToChanged" />
|
||||
</div>
|
||||
<button type="button" class="m-chip" @onclick="MarkAllRead" data-test="mark-read">
|
||||
@L["Anomaly.Filter.MarkRead"]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-anomaly-feed m-rise m-rise-3" role="region" aria-label="@L["Anomaly.Title"]" data-test="anomaly-feed">
|
||||
@if (_loading && _items.Count == 0)
|
||||
{
|
||||
<div class="m-list-empty">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
||||
<span class="m-mono">@L["Common.Loading"]</span>
|
||||
</div>
|
||||
}
|
||||
else if (_items.Count == 0)
|
||||
{
|
||||
<div class="m-list-empty" data-test="anomaly-empty">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
|
||||
@L["Common.Empty"]
|
||||
</span>
|
||||
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 50ch;">
|
||||
@L["Anomaly.Empty.NoneInRange"]
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="m-anomaly-feed__list">
|
||||
@foreach (var item in _items)
|
||||
{
|
||||
<AnomalyCard Item="item" OnClick="HandleClick" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.m-anomaly-feed__stats {
|
||||
display: flex;
|
||||
gap: var(--m-space-5);
|
||||
margin: var(--m-space-3) 0 0;
|
||||
padding: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.m-anomaly-feed__stat {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding-right: var(--m-space-4);
|
||||
border-right: 1px solid var(--m-c-rule);
|
||||
}
|
||||
.m-anomaly-feed__stat:last-child { border-right: 0; padding-right: 0; }
|
||||
.m-anomaly-feed__stat dt {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-anomaly-feed__stat dd {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--m-c-ink);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
}
|
||||
.m-anomaly-feed__stat--high dd { color: var(--m-c-anomaly); }
|
||||
.m-anomaly-feed__stat--medium dd { color: var(--m-c-accent); }
|
||||
|
||||
.m-anomaly-feed__list {
|
||||
display: grid;
|
||||
gap: var(--m-space-3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private static readonly AnomalySeverity[] _severityOptions =
|
||||
{ AnomalySeverity.Low, AnomalySeverity.Medium, AnomalySeverity.High };
|
||||
|
||||
private List<AnomalyListItem> _items = new();
|
||||
private IReadOnlyList<int> _availableSports = Array.Empty<int>();
|
||||
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<int>();
|
||||
}
|
||||
|
||||
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<AnomalyListItem>();
|
||||
}
|
||||
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<int>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
@page "/anomalies/{Id:guid}"
|
||||
@using Marathon.UI.Components
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IAnomalyBrowsingService Anomalies
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Anomaly.Title"]</PageTitle>
|
||||
|
||||
<section class="m-shell">
|
||||
@if (_loading && _detail is null)
|
||||
{
|
||||
<div class="m-list-empty">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
||||
<span class="m-mono">@L["Common.Loading"]</span>
|
||||
</div>
|
||||
}
|
||||
else if (_detail is null)
|
||||
{
|
||||
<div class="m-list-empty">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">404</span>
|
||||
<p style="color: var(--m-c-ink-soft);">@L["Anomaly.Detail.NotFound"]</p>
|
||||
<MudButton Variant="Variant.Outlined" OnClick='() => Nav.NavigateTo("/anomalies")'>
|
||||
@L["Anomaly.Detail.BackToFeed"]
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<header class="m-detail-header m-rise m-rise-1">
|
||||
<div class="m-detail-header__lockup">
|
||||
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
|
||||
@KindLabel(_detail.Item.Kind) · @_detail.Item.CountryCode · @_detail.Item.LeagueId
|
||||
</span>
|
||||
<h1 class="m-display" style="font-size: clamp(1.75rem, 3vw, 2.5rem); margin-top: var(--m-space-2);">
|
||||
@_detail.Item.EventTitle
|
||||
</h1>
|
||||
<div class="m-mono" style="margin-top: var(--m-space-2); color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.14em; font-size: 0.75rem;">
|
||||
@L["Anomaly.Card.DetectedAt"] @_detail.Item.DetectedAt.ToString("dd MMM yyyy · HH:mm:ss") · MSK
|
||||
</div>
|
||||
</div>
|
||||
<aside class="m-detail-header__odds">
|
||||
<div class="m-detail-header__odds-row">
|
||||
<span class="m-detail-header__odds-label">@L["Anomaly.Card.Score"]</span>
|
||||
<SeverityBadge Severity="_detail.Item.Severity" Score="_detail.Item.Score" />
|
||||
</div>
|
||||
<div class="m-detail-header__odds-row">
|
||||
<span class="m-detail-header__odds-label">@L["Anomaly.Card.GapSeconds"]</span>
|
||||
<span class="m-mono" data-test="suspension-duration">@FormatGap(_detail.Item.SuspensionGapSeconds)</span>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
StartIcon="@Icons.Material.Outlined.OpenInNew"
|
||||
OnClick="@(() => Nav.NavigateTo($"/events/{Uri.EscapeDataString(_detail.Item.EventId.Value)}"))"
|
||||
Class="m-detail-header__export"
|
||||
data-test="link-back-to-event">
|
||||
@L["Anomaly.Detail.LinkBackToEvent"]
|
||||
</MudButton>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<hr class="m-rule" />
|
||||
|
||||
<article class="m-card m-card--anomaly m-rise m-rise-2">
|
||||
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
|
||||
@L["Anomaly.Detail.EvidenceTitle"]
|
||||
</span>
|
||||
<div style="margin-top: var(--m-space-4);">
|
||||
<AnomalyEvidence Pre="_detail.Pre"
|
||||
Post="_detail.Post"
|
||||
SuspensionGapSeconds="_detail.Item.SuspensionGapSeconds"
|
||||
IsTwoWay="_detail.Item.IsTwoWay" />
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,9 @@
|
||||
<Field Label="@L["Settings.Workers.ResultsPollIntervalSeconds"]">
|
||||
<MudNumericField T="int" @bind-Value="_workers.ResultsPollIntervalSeconds" Min="60" Max="7200" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Workers.AnomalyDetectionEnabled"]" Hint="@L["Settings.Workers.AnomalyDetectionEnabled.Hint"]">
|
||||
<MudSwitch T="bool" @bind-Value="_workers.AnomalyDetectionEnabled" Color="Color.Primary" />
|
||||
</Field>
|
||||
|
||||
<SectionFooter OnSave="@(() => SaveSectionAsync(WorkerOptions.SectionName, _workers))" />
|
||||
</div>
|
||||
@@ -197,6 +200,7 @@
|
||||
LivePollIntervalSeconds = WorkerOpts.CurrentValue.LivePollIntervalSeconds,
|
||||
ResultsPollerEnabled = WorkerOpts.CurrentValue.ResultsPollerEnabled,
|
||||
ResultsPollIntervalSeconds = WorkerOpts.CurrentValue.ResultsPollIntervalSeconds,
|
||||
AnomalyDetectionEnabled = WorkerOpts.CurrentValue.AnomalyDetectionEnabled,
|
||||
};
|
||||
|
||||
_storage = new StorageOptions
|
||||
|
||||
@@ -119,6 +119,8 @@
|
||||
<data name="Settings.Workers.ResultsPollerEnabled"><value>Results poller enabled</value></data>
|
||||
<data name="Settings.Workers.ResultsPollerEnabled.Hint"><value>Disabled until Phase 8. Enable only after match-complete polling is implemented.</value></data>
|
||||
<data name="Settings.Workers.ResultsPollIntervalSeconds"><value>Results poll interval (sec)</value></data>
|
||||
<data name="Settings.Workers.AnomalyDetectionEnabled"><value>Anomaly detection enabled</value></data>
|
||||
<data name="Settings.Workers.AnomalyDetectionEnabled.Hint"><value>Runs the suspension-flip detector on every cycle. Disable to pause analysis without losing collected snapshots.</value></data>
|
||||
|
||||
<data name="Settings.Storage.DatabasePath"><value>SQLite path</value></data>
|
||||
<data name="Settings.Storage.ExportDirectory"><value>Export directory</value></data>
|
||||
@@ -150,6 +152,37 @@
|
||||
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
|
||||
<data name="Anomaly.Score"><value>Confidence</value></data>
|
||||
|
||||
<!-- Phase 7 — Anomaly feed UI -->
|
||||
<data name="Anomaly.Title"><value>Anomaly feed</value></data>
|
||||
<data name="Anomaly.Lede"><value>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.</value></data>
|
||||
<data name="Anomaly.Severity.Low"><value>Low</value></data>
|
||||
<data name="Anomaly.Severity.Medium"><value>Medium</value></data>
|
||||
<data name="Anomaly.Severity.High"><value>High</value></data>
|
||||
<data name="Anomaly.Filter.AnySeverity"><value>Any</value></data>
|
||||
<data name="Anomaly.Filter.Severity"><value>Min severity</value></data>
|
||||
<data name="Anomaly.Filter.Sport"><value>Sport</value></data>
|
||||
<data name="Anomaly.Filter.From"><value>Detected from</value></data>
|
||||
<data name="Anomaly.Filter.To"><value>Detected to</value></data>
|
||||
<data name="Anomaly.Filter.DateRange"><value>Date range</value></data>
|
||||
<data name="Anomaly.Filter.MarkRead"><value>Mark all read</value></data>
|
||||
<data name="Anomaly.Card.DetectedAt"><value>Detected</value></data>
|
||||
<data name="Anomaly.Card.Score"><value>Confidence</value></data>
|
||||
<data name="Anomaly.Card.Kind"><value>Kind</value></data>
|
||||
<data name="Anomaly.Card.GapSeconds"><value>Suspension gap</value></data>
|
||||
<data name="Anomaly.Evidence.Pre"><value>Before suspension</value></data>
|
||||
<data name="Anomaly.Evidence.Post"><value>After suspension</value></data>
|
||||
<data name="Anomaly.Evidence.Probability"><value>Implied prob.</value></data>
|
||||
<data name="Anomaly.Evidence.Rate"><value>Rate</value></data>
|
||||
<data name="Anomaly.Evidence.SuspensionDuration"><value>Suspension duration</value></data>
|
||||
<data name="Anomaly.Evidence.FavouriteSwap"><value>Favourite swap</value></data>
|
||||
<data name="Anomaly.Detail.EvidenceTitle"><value>Evidence timeline</value></data>
|
||||
<data name="Anomaly.Detail.LinkBackToEvent"><value>Open event</value></data>
|
||||
<data name="Anomaly.Detail.BackToFeed"><value>Back to feed</value></data>
|
||||
<data name="Anomaly.Detail.NotFound"><value>Anomaly not found — it may have been pruned.</value></data>
|
||||
<data name="Anomaly.Empty.NoneInRange"><value>No anomalies match the current filters. Loosen the severity threshold or widen the date range.</value></data>
|
||||
<data name="Anomaly.Stat.Total"><value>Total</value></data>
|
||||
<data name="Anomaly.Nav.UnreadAria"><value>Unread anomalies</value></data>
|
||||
|
||||
<!-- Phase 6 — Pre-match list / Live list / Detail / Export -->
|
||||
<data name="PreMatch.Title"><value>Pre-match schedule</value></data>
|
||||
<data name="PreMatch.Lede"><value>Upcoming events with their latest pre-match Win-1 / Draw / Win-2 odds preview. Filter by sport, country, league, or team.</value></data>
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
<data name="Settings.Workers.ResultsPollerEnabled"><value>Сборщик результатов включён</value></data>
|
||||
<data name="Settings.Workers.ResultsPollerEnabled.Hint"><value>Отключён до Phase 8. Включите только после реализации опроса match-complete.</value></data>
|
||||
<data name="Settings.Workers.ResultsPollIntervalSeconds"><value>Интервал сборщика результатов (сек)</value></data>
|
||||
<data name="Settings.Workers.AnomalyDetectionEnabled"><value>Детектор аномалий включён</value></data>
|
||||
<data name="Settings.Workers.AnomalyDetectionEnabled.Hint"><value>Запускает детектор разворота после паузы на каждом цикле. Отключение приостанавливает анализ без потери накопленных снимков.</value></data>
|
||||
|
||||
<!-- Settings — Storage -->
|
||||
<data name="Settings.Storage.DatabasePath"><value>Путь к SQLite</value></data>
|
||||
@@ -163,6 +165,37 @@
|
||||
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
|
||||
<data name="Anomaly.Score"><value>Уверенность</value></data>
|
||||
|
||||
<!-- Phase 7 — Лента аномалий -->
|
||||
<data name="Anomaly.Title"><value>Лента аномалий</value></data>
|
||||
<data name="Anomaly.Lede"><value>Сигнальный журнал «разворотов» в реальном времени. Детектор проходит каждый цикл, считает подразумеваемые вероятности до и после каждой заморозки рынка и ранжирует находки по уверенности.</value></data>
|
||||
<data name="Anomaly.Severity.Low"><value>Низкая</value></data>
|
||||
<data name="Anomaly.Severity.Medium"><value>Средняя</value></data>
|
||||
<data name="Anomaly.Severity.High"><value>Высокая</value></data>
|
||||
<data name="Anomaly.Filter.AnySeverity"><value>Любая</value></data>
|
||||
<data name="Anomaly.Filter.Severity"><value>Мин. важность</value></data>
|
||||
<data name="Anomaly.Filter.Sport"><value>Вид спорта</value></data>
|
||||
<data name="Anomaly.Filter.From"><value>Обнаружено с</value></data>
|
||||
<data name="Anomaly.Filter.To"><value>Обнаружено по</value></data>
|
||||
<data name="Anomaly.Filter.DateRange"><value>Диапазон дат</value></data>
|
||||
<data name="Anomaly.Filter.MarkRead"><value>Отметить прочитанными</value></data>
|
||||
<data name="Anomaly.Card.DetectedAt"><value>Обнаружено</value></data>
|
||||
<data name="Anomaly.Card.Score"><value>Уверенность</value></data>
|
||||
<data name="Anomaly.Card.Kind"><value>Тип</value></data>
|
||||
<data name="Anomaly.Card.GapSeconds"><value>Длительность паузы</value></data>
|
||||
<data name="Anomaly.Evidence.Pre"><value>До паузы</value></data>
|
||||
<data name="Anomaly.Evidence.Post"><value>После паузы</value></data>
|
||||
<data name="Anomaly.Evidence.Probability"><value>Подразум. вер.</value></data>
|
||||
<data name="Anomaly.Evidence.Rate"><value>Кэф</value></data>
|
||||
<data name="Anomaly.Evidence.SuspensionDuration"><value>Длительность паузы</value></data>
|
||||
<data name="Anomaly.Evidence.FavouriteSwap"><value>Смена фаворита</value></data>
|
||||
<data name="Anomaly.Detail.EvidenceTitle"><value>Хроника свидетельств</value></data>
|
||||
<data name="Anomaly.Detail.LinkBackToEvent"><value>Открыть событие</value></data>
|
||||
<data name="Anomaly.Detail.BackToFeed"><value>К ленте</value></data>
|
||||
<data name="Anomaly.Detail.NotFound"><value>Аномалия не найдена — возможно, она была удалена.</value></data>
|
||||
<data name="Anomaly.Empty.NoneInRange"><value>Под текущие фильтры аномалии не попадают. Снизьте порог важности или расширьте диапазон дат.</value></data>
|
||||
<data name="Anomaly.Stat.Total"><value>Всего</value></data>
|
||||
<data name="Anomaly.Nav.UnreadAria"><value>Непрочитанные аномалии</value></data>
|
||||
|
||||
<!-- Phase 6 — Список матчей / Лайв / Детали / Экспорт -->
|
||||
<data name="PreMatch.Title"><value>Расписание до матча</value></data>
|
||||
<data name="PreMatch.Lede"><value>Предстоящие события с последним предматчевым превью «1 / X / 2». Фильтр по виду спорта, стране, лиге и команде.</value></data>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Repository-backed anomaly browsing service. Loads anomalies + their
|
||||
/// originating events in a single pass, parses <c>EvidenceJson</c>, and shapes
|
||||
/// <see cref="AnomalyListItem"/> / <see cref="AnomalyDetailVm"/> records for
|
||||
/// the UI to consume directly.
|
||||
/// </summary>
|
||||
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<IReadOnlyList<AnomalyListItem>> ListAsync(AnomalyFilter filter, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
|
||||
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false);
|
||||
if (all.Count == 0) return Array.Empty<AnomalyListItem>();
|
||||
|
||||
// Resolve event metadata in one pass — distinct EventIds only.
|
||||
var eventLookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false);
|
||||
|
||||
var items = new List<AnomalyListItem>(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<AnomalyListItem> 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<AnomalyDetailVm?> 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<int> 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<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
|
||||
{
|
||||
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false);
|
||||
if (all.Count == 0) return Array.Empty<int>();
|
||||
|
||||
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<IReadOnlyDictionary<DomainEventId, Event>> BuildEventLookupAsync(
|
||||
IReadOnlyCollection<Anomaly> anomalies,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var distinct = anomalies
|
||||
.Select(a => a.EventId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var dict = new Dictionary<DomainEventId, Event>(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<DomainEventId, Event> 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<EvidenceDto>(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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton (per RCL) anomaly feed state — current filter, last-seen
|
||||
/// timestamp for the unread-badge, and a cached unread count.
|
||||
/// Pages produce new <see cref="AnomalyFilter"/> instances and call
|
||||
/// <see cref="UpdateFilter"/>; the OnChange event re-renders subscribers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// LastSeenUtc is held in memory; the host can persist it through the standard
|
||||
/// settings writer if desired. It seeds to <c>DateTimeOffset.MinValue</c> so
|
||||
/// the first session shows every anomaly as unread.
|
||||
/// </remarks>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Severity bucket derived from <see cref="AnomalyListItem.Score"/>.
|
||||
/// Phase 7 mapping (see backend handoff):
|
||||
/// Low = [0.30, 0.45), Medium = [0.45, 0.60), High = [0.60, 1.00].
|
||||
/// </summary>
|
||||
public enum AnomalySeverity
|
||||
{
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter state passed from a page to <see cref="IAnomalyBrowsingService"/>.
|
||||
/// All fields optional — empty filter returns the full feed.
|
||||
/// </summary>
|
||||
public sealed record AnomalyFilter(
|
||||
AnomalySeverity? MinSeverity = null,
|
||||
IReadOnlyCollection<int>? SportCodes = null,
|
||||
DateTimeOffset? From = null,
|
||||
DateTimeOffset? To = null);
|
||||
|
||||
/// <summary>
|
||||
/// Compact anomaly row used by the feed page. Designed to render without any
|
||||
/// further repository calls — pre-shaped strings + parsed evidence summary.
|
||||
/// </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);
|
||||
|
||||
/// <summary>
|
||||
/// Full anomaly aggregate for the detail page. Carries the parsed evidence
|
||||
/// snapshots plus the originating event metadata for the link-back affordance.
|
||||
/// </summary>
|
||||
public sealed record AnomalyDetailVm(
|
||||
AnomalyListItem Item,
|
||||
AnomalyEvidenceSnapshot Pre,
|
||||
AnomalyEvidenceSnapshot Post);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot bracket of the suspension window — pre or post — with raw rates,
|
||||
/// implied probabilities, and the side that was the favourite at that moment.
|
||||
/// </summary>
|
||||
public sealed record AnomalyEvidenceSnapshot(
|
||||
DateTimeOffset CapturedAt,
|
||||
decimal? Rate1,
|
||||
decimal? RateDraw,
|
||||
decimal? Rate2,
|
||||
decimal? P1,
|
||||
decimal? PDraw,
|
||||
decimal? P2,
|
||||
AnomalyFavourite Favourite);
|
||||
|
||||
/// <summary>Side that holds the lowest implied probability in a snapshot.</summary>
|
||||
public enum AnomalyFavourite
|
||||
{
|
||||
Side1,
|
||||
Draw,
|
||||
Side2,
|
||||
None,
|
||||
}
|
||||
|
||||
/// <summary>Helpers for severity bucketing.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only browsing facade over the Anomaly + Event repositories. Pages
|
||||
/// depend on this — never on <c>IAnomalyRepository</c> directly — so view-model
|
||||
/// shaping (severity buckets, evidence parsing, event metadata join) stays in
|
||||
/// one place.
|
||||
/// </summary>
|
||||
public interface IAnomalyBrowsingService
|
||||
{
|
||||
/// <summary>List anomalies matching <paramref name="filter"/>, newest first.</summary>
|
||||
Task<IReadOnlyList<AnomalyListItem>> ListAsync(AnomalyFilter filter, CancellationToken ct);
|
||||
|
||||
/// <summary>Single anomaly aggregate for the detail page; null when not found.</summary>
|
||||
Task<AnomalyDetailVm?> GetByIdAsync(Guid id, CancellationToken ct);
|
||||
|
||||
/// <summary>Count of anomalies detected after <paramref name="since"/> — drives the nav badge.</summary>
|
||||
Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct);
|
||||
|
||||
/// <summary>The set of distinct sport codes present in the anomaly feed — used to populate filter chips.</summary>
|
||||
Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -46,9 +46,11 @@ public static class UiServicesExtensions
|
||||
services.AddSingleton<ThemeState>();
|
||||
services.AddSingleton<LocaleState>();
|
||||
services.AddSingleton<EventBrowsingState>();
|
||||
services.AddSingleton<AnomalyBrowsingState>();
|
||||
|
||||
// Browsing facade — Scoped so it captures the per-circuit repository scope.
|
||||
// Browsing facades — Scoped so they capture the per-circuit repository scope.
|
||||
services.AddScoped<IEventBrowsingService, EventBrowsingService>();
|
||||
services.AddScoped<IAnomalyBrowsingService, AnomalyBrowsingService>();
|
||||
|
||||
// Settings writer — file path is host-resolved.
|
||||
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
|
||||
|
||||
@@ -34,4 +34,11 @@ public sealed class WorkerOptions
|
||||
/// Default: 300 s (5 minutes).
|
||||
/// </summary>
|
||||
public int ResultsPollIntervalSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the anomaly-detection poller should run.
|
||||
/// Default: <c>true</c> — this is the product's primary differentiator
|
||||
/// (Phase 7) and should be enabled by default.
|
||||
/// </summary>
|
||||
public bool AnomalyDetectionEnabled { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -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<AnomalyCard>(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<AnomalyCard>(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<AnomalyCard>(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<AnomalyCard>(p => p
|
||||
.Add(c => c.Item, item)
|
||||
.Add(c => c.OnClick, EventCallback.Factory.Create<AnomalyListItem>(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<AnomalyCard>(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<AnomalyCard>(p => p.Add(c => c.Item, item));
|
||||
|
||||
cut.Markup.Should().Contain("Detail.Chart.Draw");
|
||||
}
|
||||
}
|
||||
@@ -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<AnomalyEvidence>(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<AnomalyEvidence>(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<AnomalyEvidence>(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<AnomalyEvidence>(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<AnomalyEvidence>(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<AnomalyEvidence>(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");
|
||||
}
|
||||
}
|
||||
@@ -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<SeverityBadge>(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<SeverityBadge>(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<SeverityBadge>(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<SeverityBadge>(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");
|
||||
}
|
||||
}
|
||||
@@ -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<Detail>(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<Detail>(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<Detail>(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<Detail>(p => p.Add(d => d.Id, item.Id));
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var node = cut.Find("[data-test=suspension-duration]");
|
||||
node.TextContent.Should().Contain("2m 14s");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<AnomalyFeed>();
|
||||
|
||||
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<AnomalyFeed>();
|
||||
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<AnomalyFeed>();
|
||||
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<AnomalyFeed>();
|
||||
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<AnomalyFeed>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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);
|
||||
}
|
||||
@@ -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<IEventBrowsingService>(Browsing);
|
||||
Services.AddSingleton(AnomalyState);
|
||||
Services.AddSingleton<IAnomalyBrowsingService>(AnomalyBrowsing);
|
||||
|
||||
Services.AddSingleton(typeof(IStringLocalizer<>), typeof(TestLocalizer<>));
|
||||
Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
|
||||
|
||||
Reference in New Issue
Block a user