Files
maraphon-app/tests/Marathon.UI.Tests/Components/AnomalyEvidenceTests.cs
T
alexei.dolgolyov 12208a4762 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.
2026-05-05 13:39:39 +03:00

116 lines
4.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
}