feat(anomalies): filter the feed by detector kind

Adds a detector-kind chip row to the anomaly feed (SuspensionFlip / SteamMove /
SuspensionFreeze / OverroundCompression), multi-select like the sport filter — so
with four detectors live you can slice the feed to a single signal type. The kind
set lives on AnomalyFilter and filters in-memory alongside severity/sport, persisted
via AnomalyBrowsingState like the other filters.

- AnomalyFilter.Kinds + AnomalyBrowsingService in-memory Where clause; feed chip
  row + ToggleKind/KindLabel; en/ru resx (Anomaly.Filter.Kind).
- 2 tests: kind-filtered subset + no-filter returns all kinds.
This commit is contained in:
2026-05-29 11:38:06 +03:00
parent 6e12dd73c3
commit 34cc72fd2d
6 changed files with 122 additions and 1 deletions
@@ -82,6 +82,23 @@
</div> </div>
} }
<div class="m-list-toolbar__row m-list-toolbar__chips">
<span class="m-list-toolbar__label">@L["Anomaly.Filter.Kind"]</span>
@foreach (var kind in _kindOptions)
{
var localKind = kind;
var active = _filter.Kinds is { Count: > 0 } ks && ks.Contains(localKind);
<button type="button"
class="m-chip @(active ? "is-active" : null)"
aria-pressed="@active"
data-test="kind-chip"
data-kind="@localKind"
@onclick="() => ToggleKind(localKind)">
@KindLabel(localKind)
</button>
}
</div>
<div class="m-list-toolbar__row"> <div class="m-list-toolbar__row">
<div class="m-list-toolbar__group"> <div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["Anomaly.Filter.From"]</label> <label class="m-list-toolbar__label">@L["Anomaly.Filter.From"]</label>
@@ -178,6 +195,9 @@
private static readonly AnomalySeverity[] _severityOptions = private static readonly AnomalySeverity[] _severityOptions =
{ AnomalySeverity.Low, AnomalySeverity.Medium, AnomalySeverity.High }; { AnomalySeverity.Low, AnomalySeverity.Medium, AnomalySeverity.High };
private static readonly AnomalyKind[] _kindOptions =
{ AnomalyKind.SuspensionFlip, AnomalyKind.SteamMove, AnomalyKind.SuspensionFreeze, AnomalyKind.OverroundCompression };
private List<AnomalyListItem> _items = new(); private List<AnomalyListItem> _items = new();
private IReadOnlyList<int> _availableSports = Array.Empty<int>(); private IReadOnlyList<int> _availableSports = Array.Empty<int>();
private bool _loading = true; private bool _loading = true;
@@ -251,6 +271,22 @@
return UpdateFilter(_filter with { SportCodes = existing.Count == 0 ? null : existing }); return UpdateFilter(_filter with { SportCodes = existing.Count == 0 ? null : existing });
} }
private Task ToggleKind(AnomalyKind kind)
{
var existing = _filter.Kinds?.ToList() ?? new List<AnomalyKind>();
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) private async Task OnFromChanged(ChangeEventArgs e)
{ {
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
@@ -190,6 +190,7 @@
<data name="Anomaly.Filter.AnySeverity"><value>Any</value></data> <data name="Anomaly.Filter.AnySeverity"><value>Any</value></data>
<data name="Anomaly.Filter.Severity"><value>Min severity</value></data> <data name="Anomaly.Filter.Severity"><value>Min severity</value></data>
<data name="Anomaly.Filter.Sport"><value>Sport</value></data> <data name="Anomaly.Filter.Sport"><value>Sport</value></data>
<data name="Anomaly.Filter.Kind"><value>Kind</value></data>
<data name="Anomaly.Filter.From"><value>Detected from</value></data> <data name="Anomaly.Filter.From"><value>Detected from</value></data>
<data name="Anomaly.Filter.To"><value>Detected to</value></data> <data name="Anomaly.Filter.To"><value>Detected to</value></data>
<data name="Anomaly.Filter.DateRange"><value>Date range</value></data> <data name="Anomaly.Filter.DateRange"><value>Date range</value></data>
@@ -203,6 +203,7 @@
<data name="Anomaly.Filter.AnySeverity"><value>Любая</value></data> <data name="Anomaly.Filter.AnySeverity"><value>Любая</value></data>
<data name="Anomaly.Filter.Severity"><value>Мин. важность</value></data> <data name="Anomaly.Filter.Severity"><value>Мин. важность</value></data>
<data name="Anomaly.Filter.Sport"><value>Вид спорта</value></data> <data name="Anomaly.Filter.Sport"><value>Вид спорта</value></data>
<data name="Anomaly.Filter.Kind"><value>Тип</value></data>
<data name="Anomaly.Filter.From"><value>Обнаружено с</value></data> <data name="Anomaly.Filter.From"><value>Обнаружено с</value></data>
<data name="Anomaly.Filter.To"><value>Обнаружено по</value></data> <data name="Anomaly.Filter.To"><value>Обнаружено по</value></data>
<data name="Anomaly.Filter.DateRange"><value>Диапазон дат</value></data> <data name="Anomaly.Filter.DateRange"><value>Диапазон дат</value></data>
@@ -59,6 +59,11 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
filtered = filtered.Where(i => sports.Contains(i.Sport.Value)); 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 return filtered
.OrderByDescending(static i => i.DetectedAt) .OrderByDescending(static i => i.DetectedAt)
.ToList(); .ToList();
@@ -24,7 +24,8 @@ public sealed record AnomalyFilter(
AnomalySeverity? MinSeverity = null, AnomalySeverity? MinSeverity = null,
IReadOnlyCollection<int>? SportCodes = null, IReadOnlyCollection<int>? SportCodes = null,
DateTimeOffset? From = null, DateTimeOffset? From = null,
DateTimeOffset? To = null); DateTimeOffset? To = null,
IReadOnlyCollection<AnomalyKind>? Kinds = null);
/// <summary> /// <summary>
/// Compact anomaly row used by the feed page. Designed to render without any /// Compact anomaly row used by the feed page. Designed to render without any
@@ -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;
/// <summary>
/// Covers the in-memory filtering of <see cref="AnomalyBrowsingService.ListAsync"/> —
/// specifically the detector-kind filter added for the feed.
/// </summary>
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<IAnomalyRepository>();
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
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);
/// <summary>Stubs the anomaly query AND a matching event per anomaly (events exist via FK).</summary>
private void Setup(params Anomaly[] anomalies)
{
_anomalies
.ListByDateRangeAsync(Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
.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<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.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);
}
}