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, decimal score = 0.70m, DateTimeOffset? detectedAt = null) => new(Guid.NewGuid(), new EventId($"evt-{_seq++}"), detectedAt ?? T0, kind, score, 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); } [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); } }