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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user