12208a4762
Frontend portion of Phase 7. Backend (commit a6ff368) had already shipped
the AnomalyDetector, DetectAnomaliesUseCase, AnomalyDetectionPoller, and
all DI wiring. This commit adds the user-facing surfaces.
New surfaces (Option A routing — folder-per-feature):
- Pages/Anomalies/AnomalyFeed.razor (@page /anomalies) — replaces the
Phase 5 placeholder with a severity-coded card stream, filter chips
(severity / sport / date), unread-count summary, 'Mark all read' action.
- Pages/Anomalies/Detail.razor (@page /anomalies/{id:guid}) — m-detail-header
lockup + AnomalyEvidence panel + back link to /events/{eventCode}.
New components:
- AnomalyCard.razor — severity-tinted left border (signal-red on High,
amber on Medium, neutral on Low) + SeverityBadge pill + sport icon +
pre→post tabular-mono rate strip + relative time. Click navigates.
- SeverityBadge.razor — small pill mapping score → bucket per backend
handoff (Low <0.45, Medium <0.60, High ≥0.60).
- AnomalyEvidence.razor — two-column pre/post panel with implied-prob
bars + raw rates; favourite-swap callout when argmax(p_pre) ≠ argmax(p_post);
signal-red 3px left border on the post column. Handles 2-way (no draw).
State + service split mirrors Phase 6's pattern:
- AnomalyViewModels.cs — AnomalyListItem / AnomalyDetailVm / Severity enum
/ AnomalyEvidenceSnapshot record. Severity computed in the view-model
from Score.
- IAnomalyBrowsingService / AnomalyBrowsingService — wraps IAnomalyRepository,
parses Anomaly.EvidenceJson into typed view-models, applies filters
client-side. Methods: ListAsync(filter, ct), GetByIdAsync(id, ct),
GetUnreadCountAsync(since, ct).
- AnomalyBrowsingState — Singleton holding AnomalyFilter (severity threshold,
sport set, date range) + LastSeenUtc + cached UnreadCount. OnChange event.
Nav badge:
- NavBody.razor subscribes to AnomalyBrowsingState.OnChange, renders a
pulsing red m-nav__badge when UnreadCount > 0. Badge resets when the
user clicks 'Mark all read' on the feed toolbar.
Settings toggle:
- Settings.razor — added Workers:AnomalyDetectionEnabled toggle (backend
added the flag). Localized via Settings.Worker.AnomalyDetectionEnabled.
- Marathon.UI.Services.WorkerOptions mirror — added AnomalyDetectionEnabled
(default true).
Localization: +30 RU/EN keys following the dot-segmented convention
(Anomaly.*, Settings.Worker.AnomalyDetectionEnabled). Full key parity verified.
Tests (+31 bUnit, all passing):
- AnomalyFeedTests, AnomalyDetailTests
- AnomalyCardTests, SeverityBadgeTests, AnomalyEvidenceTests
- FakeAnomalyBrowsingService support fake registered in MarathonTestContext.
Routing: deleted the Phase 5 Pages/Anomalies.razor placeholder; new feed
page lives at Pages/Anomalies/AnomalyFeed.razor.
Build: 0 warnings, 0 errors.
Tests: Domain 109 + Application 19 + Infrastructure 80 + UI 68 = 276/276
(baseline 245, +31 new bUnit tests, no regressions).
Phase 7 status: ✅ Done (backend + frontend both complete, awaiting review).
Known deferral: AnomalyBrowsingState.LastSeenUtc is in-memory only; the
unread-count badge resets on app restart. Acceptable for now; Phase 9 may
extend ISettingsWriter or add an ILastSeenStore.
320 lines
18 KiB
Markdown
320 lines
18 KiB
Markdown
# Phase 7: Anomaly Detection (Suspension + Flip)
|
||
|
||
**Status:** ✅ Done
|
||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||
**Domain:** fullstack
|
||
**Implementer:** Sonnet (backend portion) + Opus (UI portion, with frontend-design)
|
||
**Depends on:** Phase 4 (snapshot pipeline) + Phase 6 (UI patterns)
|
||
|
||
## Objective
|
||
|
||
Detect the **odds-flip anomaly** described in customer TZ §3: bookmaker freezes betting
|
||
on a live event, then re-opens with inverted underdog/favorite odds. Persist anomalies
|
||
and surface them in a dedicated UI feed page so the user can act on them.
|
||
|
||
## Tasks
|
||
|
||
### Backend (Sonnet) ✅ COMPLETE
|
||
|
||
- [x] Implement `Marathon.Domain/AnomalyDetection/AnomalyDetector.cs`:
|
||
- Pure domain logic — takes `IReadOnlyList<OddsSnapshot>` for an event, returns
|
||
`IReadOnlyList<Anomaly>`
|
||
- Detect suspension intervals: gaps between snapshots > `SuspensionGapSeconds`
|
||
(configurable)
|
||
- For each suspension, compute pre-suspension and post-suspension implied
|
||
probability vectors `(p1, pDraw, p2)` from Win-1/Draw/Win-2 rates
|
||
- Compute flip score: `max(|p_post[i] − p_pre[i]|)` across i ∈ {1, draw, 2}
|
||
- If flip score ≥ `OddsFlipThreshold` AND the favourite changed (argmax differs),
|
||
emit an `Anomaly(Kind=SuspensionFlip, Score, EvidenceJson)` where `EvidenceJson`
|
||
contains the snapshots bracketing the suspension
|
||
- [x] Add `AnomalyOptions` POCO bound to `Anomaly:*` (in `Marathon.Application/Configuration/`):
|
||
```csharp
|
||
public sealed class AnomalyOptions {
|
||
public int SuspensionGapSeconds { get; init; } = 60;
|
||
public decimal OddsFlipThreshold { get; init; } = 0.30m;
|
||
public int MinSnapshotCount { get; init; } = 3;
|
||
public int DetectionIntervalSeconds { get; init; } = 60;
|
||
}
|
||
```
|
||
- [x] Implement `DetectAnomaliesUseCase` in `Marathon.Application/UseCases/`:
|
||
- Iterate over all events and load snapshots from last 24 h
|
||
- Invoke `AnomalyDetector` per event
|
||
- Persist new anomalies via `IAnomalyRepository` with dedup logic
|
||
- [x] Implement `AnomalyDetectionPoller : BackgroundService` in
|
||
`Marathon.Infrastructure/Workers/`:
|
||
- Runs every `Anomaly:DetectionIntervalSeconds` (default 60s)
|
||
- Calls `DetectAnomaliesUseCase`
|
||
- Gated by `Workers:AnomalyDetectionEnabled` (default `true`)
|
||
- [x] Add `WorkerOptions.AnomalyDetectionEnabled` (default `true`)
|
||
- [x] Register `DetectAnomaliesUseCase` as Scoped in `ApplicationModule`
|
||
- [x] Bind `AnomalyOptions` and register `AnomalyDetectionPoller` in `InfrastructureModule`
|
||
- [x] Update `appsettings.json` — add `Workers:AnomalyDetectionEnabled: true`
|
||
(all 4 `Anomaly:*` keys already existed from Phase 5)
|
||
- [x] Backend tests in `Marathon.Domain.Tests/AnomalyDetection/AnomalyDetectorTests.cs` (10 tests):
|
||
- Empty snapshot list → 0 anomalies ✓
|
||
- Below `minSnapshotCount` → 0 anomalies ✓
|
||
- Pre-match-only snapshots → 0 anomalies ✓
|
||
- No suspension (regular intervals) → 0 anomalies ✓
|
||
- Suspension but odds shift below threshold → 0 anomalies ✓
|
||
- Suspension + favourite flip (2-way) → 1 anomaly ✓
|
||
- Score calculation correct for known inputs ✓
|
||
- Tennis (no draw) → 1 anomaly ✓
|
||
- Multiple suspensions → multiple anomalies ✓
|
||
- EvidenceJson contains pre/post probability vectors and rates ✓
|
||
- Determinism: same input → same output ✓
|
||
- 3-way market flip (draw becomes favourite) → 1 anomaly ✓
|
||
- Mixed pre-match + live snapshots → only live analysed ✓
|
||
- [x] Application tests in `Marathon.Application.Tests/UseCases/DetectAnomaliesUseCaseTests.cs` (4 tests):
|
||
- Iterates events, calls detector, persists new anomalies ✓
|
||
- Skips already-persisted anomalies (dedup logic) ✓
|
||
- Tolerates per-event failures (one event throwing doesn't abort the cycle) ✓
|
||
- Returns count of new anomalies ✓
|
||
|
||
### Frontend (Opus + frontend-design) ✅ COMPLETE
|
||
|
||
- [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, 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
|
||
|
||
### Backend (done)
|
||
- `src/Marathon.Domain/AnomalyDetection/AnomalyDetector.cs` ✅ created
|
||
- `src/Marathon.Domain/AnomalyDetection/SuspensionInterval.cs` ✅ created
|
||
- `src/Marathon.Application/Configuration/AnomalyOptions.cs` ✅ created
|
||
- `src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs` ✅ created
|
||
- `src/Marathon.Application/ApplicationModule.cs` ✅ modified (added `DetectAnomaliesUseCase` registration)
|
||
- `src/Marathon.Infrastructure/Workers/AnomalyDetectionPoller.cs` ✅ created
|
||
- `src/Marathon.Infrastructure/Configuration/WorkerOptions.cs` ✅ modified (added `AnomalyDetectionEnabled`)
|
||
- `src/Marathon.Infrastructure/InfrastructureModule.cs` ✅ modified (added `AnomalyOptions` binding + poller)
|
||
- `src/Marathon.Hosts.WpfBlazor/appsettings.json` ✅ modified (added `Workers:AnomalyDetectionEnabled`)
|
||
- `tests/Marathon.Domain.Tests/AnomalyDetection/AnomalyDetectorTests.cs` ✅ created (13 tests)
|
||
- `tests/Marathon.Application.Tests/UseCases/DetectAnomaliesUseCaseTests.cs` ✅ created (4 tests)
|
||
|
||
### Frontend (UI agent owns)
|
||
- `src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor`
|
||
- `src/Marathon.UI/Components/AnomalyCard.razor`
|
||
- `src/Marathon.UI/Services/IAnomalyBrowsingService.cs`
|
||
- `src/Marathon.UI/Services/AnomalyBrowsingService.cs`
|
||
- `src/Marathon.UI/Services/AnomalyBrowsingState.cs`
|
||
- `src/Marathon.UI/Services/AnomalyViewModels.cs`
|
||
- `src/Marathon.UI/Resources/SharedResource.ru.resx` (append new keys)
|
||
- `src/Marathon.UI/Resources/SharedResource.en.resx` (append new keys)
|
||
- `src/Marathon.UI/MainLayout.razor` or `NavBody.razor` (anomaly nav entry)
|
||
- `tests/Marathon.UI.Tests/Pages/Anomalies/**`
|
||
|
||
## Acceptance Criteria
|
||
|
||
- [x] Compiles (Big Bang).
|
||
- [x] `AnomalyDetector` is a pure function — no I/O, no DI dependencies.
|
||
- [x] Configurable thresholds via `appsettings.json`.
|
||
- [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
|
||
|
||
- This is the **product's actual differentiator** — quality of detection logic and
|
||
evidence presentation matters. Spend time getting the score formula right.
|
||
- Implied probability formula: `p = 1 / odds` (then normalize so they sum to 1).
|
||
- Big Bang: compile-only smoke check.
|
||
|
||
## Review Checklist
|
||
|
||
- [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
|
||
- [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
|
||
|
||
### Handoff to Phase 7 Frontend (UI) Agent
|
||
|
||
> **Read this section first.** The backend is fully implemented. You own all `Marathon.UI`
|
||
> files listed above. Do NOT touch any backend files.
|
||
|
||
---
|
||
|
||
#### What the backend provides
|
||
|
||
**`DetectAnomaliesUseCase.ExecuteAsync(CancellationToken)`**
|
||
- Returns `Task<int>` (count of new anomalies persisted this cycle).
|
||
- Called automatically by `AnomalyDetectionPoller` every 60 s (default).
|
||
- You do NOT call this from the UI — it is worker-driven.
|
||
- The UI only reads from `IAnomalyRepository`.
|
||
|
||
**`AnomalyDetector` — detection formula (for rendering evidence)**
|
||
- Implied probability: `p_i = (1 / rate_i)` for each win side.
|
||
- Normalisation: divide each `p_i` by the sum of all raw `p_i` values → they sum to 1.
|
||
- Flip score: `max(|p_post[i] − p_pre[i]|)` over i ∈ {p1, pDraw?, p2}.
|
||
- Favourite-changed test: `argmax(p_pre) != argmax(p_post)`.
|
||
- An anomaly is emitted only if BOTH conditions hold: score ≥ threshold AND favourite changed.
|
||
|
||
**`IAnomalyRepository`** — the UI service should call:
|
||
- `ListAsync(CancellationToken)` — all anomalies for the feed page (paginate client-side).
|
||
- `GetAsync(Guid id, CancellationToken)` — single anomaly for a detail view.
|
||
- There is no `ListByEventAsync` on `IAnomalyRepository` (only on `ISnapshotRepository`).
|
||
If you need anomalies for a specific event, filter the full list by `EventId`.
|
||
|
||
**`Anomaly` entity** — fields available to the UI:
|
||
```csharp
|
||
Guid Id // GUID primary key
|
||
EventId EventId // bookmaker event code (e.g. "26456117")
|
||
DateTimeOffset DetectedAt // Moscow TZ (UTC+3)
|
||
AnomalyKind Kind // currently always SuspensionFlip
|
||
decimal Score // normalised [0, 1] — the largest implied-prob delta
|
||
string EvidenceJson // see shape below
|
||
```
|
||
|
||
**`Anomaly.EvidenceJson` shape:**
|
||
```json
|
||
{
|
||
"suspensionGapSeconds": 90,
|
||
"preSuspension": {
|
||
"capturedAt": "2026-05-10T18:00:00+03:00",
|
||
"p1": 0.755,
|
||
"pDraw": null,
|
||
"p2": 0.245,
|
||
"rate1": 1.3,
|
||
"rateDraw": null,
|
||
"rate2": 4.0
|
||
},
|
||
"postSuspension": {
|
||
"capturedAt": "2026-05-10T18:01:30+03:00",
|
||
"p1": 0.245,
|
||
"pDraw": null,
|
||
"p2": 0.755,
|
||
"rate1": 4.0,
|
||
"rateDraw": null,
|
||
"rate2": 1.3
|
||
}
|
||
}
|
||
```
|
||
- `pDraw` / `rateDraw` are `null` for 2-way markets (tennis, etc.).
|
||
- Use `System.Text.Json.JsonDocument.Parse(anomaly.EvidenceJson)` to deserialise in the UI.
|
||
Or define a `EvidenceDto` record in `AnomalyViewModels.cs` and use `JsonSerializer.Deserialize<EvidenceDto>`.
|
||
|
||
**Recommended severity buckets** (for color-coding):
|
||
| Severity | Score range | MudBlazor color suggestion |
|
||
|----------|-------------|---------------------------|
|
||
| Low | 0.30–0.45 | `Color.Warning` |
|
||
| Medium | 0.45–0.60 | `Color.Error` |
|
||
| High | 0.60+ | deep red / `Color.Error` + pulsing badge |
|
||
|
||
---
|
||
|
||
#### Settings page addition (UI agent must wire)
|
||
|
||
`Workers:AnomalyDetectionEnabled` (`bool`, default `true`) was added to `WorkerOptions`
|
||
and `appsettings.json`. The Phase 5 Settings page needs a toggle for it.
|
||
The existing pattern is the same as `LivePollerEnabled` and `UpcomingPollerEnabled`.
|
||
|
||
---
|
||
|
||
#### Localization keys to add
|
||
|
||
Append these to both `SharedResource.ru.resx` and `SharedResource.en.resx`:
|
||
|
||
| Key | EN value | RU value |
|
||
|------------------------------|------------------------------|------------------------------------|
|
||
| `Anomaly.Title` | Anomaly Feed | Лента аномалий |
|
||
| `Anomaly.Severity.Low` | Low | Низкая |
|
||
| `Anomaly.Severity.Medium` | Medium | Средняя |
|
||
| `Anomaly.Severity.High` | High | Высокая |
|
||
| `Anomaly.Card.DetectedAt` | Detected at | Обнаружено |
|
||
| `Anomaly.Card.Score` | Score | Оценка |
|
||
| `Anomaly.Card.Kind.SuspensionFlip` | Suspension Flip | Переворот после паузы |
|
||
| `Anomaly.Card.GapSeconds` | Suspension gap | Длительность паузы |
|
||
| `Anomaly.Evidence.PreSuspension` | Before suspension | До паузы |
|
||
| `Anomaly.Evidence.PostSuspension` | After suspension | После паузы |
|
||
| `Anomaly.Evidence.Probability` | Implied prob. | Вероятность |
|
||
| `Anomaly.Evidence.Rate` | Rate | Коэффициент |
|
||
| `Anomaly.Filter.Severity` | Min severity | Минимальная важность |
|
||
| `Anomaly.Filter.Sport` | Sport | Вид спорта |
|
||
| `Anomaly.Filter.DateRange` | Date range | Диапазон дат |
|
||
| `Anomaly.Empty` | No anomalies detected yet. | Аномалии пока не обнаружены. |
|
||
| `Settings.AnomalyDetection` | Anomaly detection | Обнаружение аномалий |
|
||
| `Settings.AnomalyDetectionEnabled` | Enable anomaly detection | Включить обнаружение аномалий |
|
||
|
||
---
|
||
|
||
#### Integration pattern for the UI service
|
||
|
||
Follow the same split as `EventBrowsingService` (Scoped) + `EventBrowsingState` (Singleton)
|
||
documented in CONTEXT.md Phase 6 notes. Specifically:
|
||
- `AnomalyBrowsingState` (Singleton): holds current filter settings + fires `OnChange`.
|
||
- `AnomalyBrowsingService` (Scoped): resolves `IAnomalyRepository` from the DI scope,
|
||
loads anomalies, and maps to view-models (`AnomalyListItem`, `AnomalyDetail`).
|
||
- `AnomalyListItem` view-model should include `Severity` (computed from `Score`),
|
||
pre-rendered display strings, and the parsed `EvidenceDto`.
|
||
|
||
---
|
||
|
||
#### 🟡 Known gaps / deferred items
|
||
|
||
- **No "last detection run" tracking.** The use case currently loads the last 24 h of
|
||
snapshots for ALL events on every cycle. A Phase 8/9 optimisation: track last-run
|
||
timestamp per event to limit the snapshot window. Flag this in the UI as "best-effort
|
||
coverage window: last 24 h".
|
||
- **`Settings.razor` AnomalyDetectionEnabled toggle** — backend option exists, UI wiring
|
||
is the UI agent's responsibility.
|
||
- **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.
|