36178e6d1b
Adds a sort chip row to the anomaly feed — newest (default), highest score, or longest suspension gap — replacing the fixed newest-first order. DetectedAt is the tiebreak so order stays stable. - AnomalySort enum + AnomalyFilter.Sort (default Newest, so existing constructions are unaffected); AnomalyBrowsingService applies it; feed sort chips + SetSort/SortLabel; en/ru resx. - 2 tests: default newest-first + highest-score ordering.
104 lines
3.9 KiB
C#
104 lines
3.9 KiB
C#
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, decimal score = 0.70m, DateTimeOffset? detectedAt = null) =>
|
|
new(Guid.NewGuid(), new EventId($"evt-{_seq++}"), detectedAt ?? T0, kind, score, 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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListAsync_DefaultSort_IsNewestFirst()
|
|
{
|
|
Setup(
|
|
Make(AnomalyKind.SuspensionFlip, detectedAt: T0.AddHours(-2)),
|
|
Make(AnomalyKind.SteamMove, detectedAt: T0));
|
|
|
|
var items = await CreateSut().ListAsync(new AnomalyFilter(), CancellationToken.None);
|
|
|
|
items.First().DetectedAt.Should().Be(T0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListAsync_SortsByHighestScore()
|
|
{
|
|
Setup(
|
|
Make(AnomalyKind.SuspensionFlip, score: 0.40m),
|
|
Make(AnomalyKind.SteamMove, score: 0.80m),
|
|
Make(AnomalyKind.SuspensionFlip, score: 0.60m));
|
|
|
|
var items = await CreateSut().ListAsync(
|
|
new AnomalyFilter(Sort: AnomalySort.HighestScore), CancellationToken.None);
|
|
|
|
items.Select(i => i.Score).Should().ContainInOrder(0.80m, 0.60m, 0.40m);
|
|
}
|
|
}
|