Files
maraphon-app/tests/Marathon.UI.Tests/Pages/Anomalies/AnomalyFeedTests.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

114 lines
3.9 KiB
C#

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);
}
}