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.
116 lines
4.1 KiB
C#
116 lines
4.1 KiB
C#
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");
|
||
}
|
||
}
|