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:
@@ -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