feat(anomalies): sort the feed (newest / top score / longest gap)

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.
This commit is contained in:
2026-05-29 11:55:46 +03:00
parent 67f2ae130c
commit 36178e6d1b
6 changed files with 93 additions and 7 deletions
@@ -29,8 +29,8 @@ public sealed class AnomalyBrowsingServiceTests
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);
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)
@@ -74,4 +74,30 @@ public sealed class AnomalyBrowsingServiceTests
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);
}
}