diff --git a/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor b/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor index 7a79bdd..0eb82f7 100644 --- a/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor +++ b/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor @@ -82,6 +82,23 @@ } +
+ @L["Anomaly.Filter.Kind"] + @foreach (var kind in _kindOptions) + { + var localKind = kind; + var active = _filter.Kinds is { Count: > 0 } ks && ks.Contains(localKind); + + } +
+
@@ -178,6 +195,9 @@ private static readonly AnomalySeverity[] _severityOptions = { AnomalySeverity.Low, AnomalySeverity.Medium, AnomalySeverity.High }; + private static readonly AnomalyKind[] _kindOptions = + { AnomalyKind.SuspensionFlip, AnomalyKind.SteamMove, AnomalyKind.SuspensionFreeze, AnomalyKind.OverroundCompression }; + private List _items = new(); private IReadOnlyList _availableSports = Array.Empty(); private bool _loading = true; @@ -251,6 +271,22 @@ return UpdateFilter(_filter with { SportCodes = existing.Count == 0 ? null : existing }); } + private Task ToggleKind(AnomalyKind kind) + { + var existing = _filter.Kinds?.ToList() ?? new List(); + if (!existing.Remove(kind)) existing.Add(kind); + return UpdateFilter(_filter with { Kinds = existing.Count == 0 ? null : existing }); + } + + private string KindLabel(AnomalyKind kind) => kind switch + { + AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"], + AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"], + AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"], + AnomalyKind.OverroundCompression => L["Anomaly.Kind.OverroundCompression"], + _ => kind.ToString(), + }; + private async Task OnFromChanged(ChangeEventArgs e) { if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 3b0800f..ef32bc4 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -190,6 +190,7 @@ Any Min severity Sport + Kind Detected from Detected to Date range diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index ee6c3b8..da96b23 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -203,6 +203,7 @@ Любая Мин. важность Вид спорта + Тип Обнаружено с Обнаружено по Диапазон дат diff --git a/src/Marathon.UI/Services/AnomalyBrowsingService.cs b/src/Marathon.UI/Services/AnomalyBrowsingService.cs index 7286ce7..dd08858 100644 --- a/src/Marathon.UI/Services/AnomalyBrowsingService.cs +++ b/src/Marathon.UI/Services/AnomalyBrowsingService.cs @@ -59,6 +59,11 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService filtered = filtered.Where(i => sports.Contains(i.Sport.Value)); } + if (filter.Kinds is { Count: > 0 } kinds) + { + filtered = filtered.Where(i => kinds.Contains(i.Kind)); + } + return filtered .OrderByDescending(static i => i.DetectedAt) .ToList(); diff --git a/src/Marathon.UI/Services/AnomalyViewModels.cs b/src/Marathon.UI/Services/AnomalyViewModels.cs index 6d0e24f..7c60a94 100644 --- a/src/Marathon.UI/Services/AnomalyViewModels.cs +++ b/src/Marathon.UI/Services/AnomalyViewModels.cs @@ -24,7 +24,8 @@ public sealed record AnomalyFilter( AnomalySeverity? MinSeverity = null, IReadOnlyCollection? SportCodes = null, DateTimeOffset? From = null, - DateTimeOffset? To = null); + DateTimeOffset? To = null, + IReadOnlyCollection? Kinds = null); /// /// Compact anomaly row used by the feed page. Designed to render without any diff --git a/tests/Marathon.UI.Tests/Services/AnomalyBrowsingServiceTests.cs b/tests/Marathon.UI.Tests/Services/AnomalyBrowsingServiceTests.cs new file mode 100644 index 0000000..4b18a3d --- /dev/null +++ b/tests/Marathon.UI.Tests/Services/AnomalyBrowsingServiceTests.cs @@ -0,0 +1,77 @@ +using FluentAssertions; +using Marathon.Application.Abstractions; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using Marathon.UI.Services; +using NSubstitute; + +namespace Marathon.UI.Tests.Services; + +/// +/// Covers the in-memory filtering of — +/// specifically the detector-kind filter added for the feed. +/// +public sealed class AnomalyBrowsingServiceTests +{ + private static readonly DateTimeOffset T0 = new(2026, 5, 20, 18, 0, 0, TimeSpan.FromHours(3)); + + // Minimal valid 2-way evidence so TryProject succeeds (kind is what we filter on). + private const string Evidence = """ + {"suspensionGapSeconds":90, + "preSuspension":{"capturedAt":"2026-05-20T18:00:00+03:00","p1":0.6,"p2":0.4,"rate1":1.6,"rate2":2.5}, + "postSuspension":{"capturedAt":"2026-05-20T18:02:00+03:00","p1":0.4,"p2":0.6,"rate1":2.5,"rate2":1.6}} + """; + + private readonly IAnomalyRepository _anomalies = Substitute.For(); + private readonly IEventRepository _events = Substitute.For(); + + private AnomalyBrowsingService CreateSut() => new(_anomalies, _events); + + private int _seq; + private Anomaly Make(AnomalyKind kind) => + new(Guid.NewGuid(), new EventId($"evt-{_seq++}"), T0, kind, 0.70m, Evidence); + + /// Stubs the anomaly query AND a matching event per anomaly (events exist via FK). + private void Setup(params Anomaly[] anomalies) + { + _anomalies + .ListByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(anomalies); + + var events = anomalies.ToDictionary( + a => a.EventId, + a => new Event(a.EventId, new SportCode(11), "England", "league", string.Empty, T0.AddDays(1), "Home", "Away")); + _events + .GetManyAsync(Arg.Any>(), Arg.Any()) + .Returns(events); + } + + [Fact] + public async Task ListAsync_FiltersByKind() + { + Setup( + Make(AnomalyKind.SuspensionFlip), + Make(AnomalyKind.SteamMove), + Make(AnomalyKind.OverroundCompression)); + + var filter = new AnomalyFilter(Kinds: new[] { AnomalyKind.SuspensionFlip, AnomalyKind.OverroundCompression }); + var items = await CreateSut().ListAsync(filter, CancellationToken.None); + + items.Select(i => i.Kind).Should() + .BeEquivalentTo(new[] { AnomalyKind.SuspensionFlip, AnomalyKind.OverroundCompression }); + } + + [Fact] + public async Task ListAsync_NoKindFilter_ReturnsAllKinds() + { + Setup( + Make(AnomalyKind.SuspensionFlip), + Make(AnomalyKind.SteamMove), + Make(AnomalyKind.OverroundCompression)); + + var items = await CreateSut().ListAsync(new AnomalyFilter(), CancellationToken.None); + + items.Should().HaveCount(3); + } +}