using FluentAssertions; using Marathon.Application.Abstractions; using Marathon.Application.Reporting; using Marathon.Application.UseCases; using Marathon.Domain.AnomalyDetection; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; namespace Marathon.Application.Tests.UseCases; /// /// Unit tests for covering empty /// state, mixed hit/miss aggregation, unresolved partitioning, and missing /// event metadata fallbacks. /// public sealed class EvaluateAnomalyOutcomesUseCaseTests { private readonly IAnomalyRepository _anomalies = Substitute.For(); private readonly IEventRepository _events = Substitute.For(); private readonly IResultRepository _results = Substitute.For(); public EvaluateAnomalyOutcomesUseCaseTests() { // Use cases batch event/result loads via GetManyAsync; route those through // the per-id GetAsync stubs each test already configures. TestFixtures.BridgeGetMany(_events); TestFixtures.BridgeGetMany(_results); } private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); private static readonly DateTimeOffset BaseTime = new(2026, 5, 10, 18, 0, 0, MoscowOffset); // Flip evidence with Side1 → Side2 reversal. private const string FlipEvidence = """ { "suspensionGapSeconds": 90, "preSuspension": { "capturedAt": "2026-05-10T18:00:00+03:00", "p1": 0.55, "pDraw": 0.20, "p2": 0.25, "rate1": 1.8, "rateDraw": 4.5, "rate2": 4.0 }, "postSuspension": { "capturedAt": "2026-05-10T18:02:30+03:00", "p1": 0.25, "pDraw": 0.20, "p2": 0.55, "rate1": 4.0, "rateDraw": 4.5, "rate2": 1.8 } } """; private EvaluateAnomalyOutcomesUseCase CreateSut() => new(_anomalies, _events, _results, NullLogger.Instance); private static Anomaly MakeAnomaly(EventId eventId, decimal score) => new(Guid.NewGuid(), eventId, BaseTime, AnomalyKind.SuspensionFlip, score, FlipEvidence); private static Event MakeEvent(EventId id, int sportCode) => new(id, new SportCode(sportCode), "BY", "L1", "Cat", BaseTime, "Team A", "Team B"); [Fact] public async Task Should_ReturnEmptyReport_When_NoAnomaliesExist() { _anomalies.ListAsync(Arg.Any()) .Returns(Array.Empty().ToList().AsReadOnly()); var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.TotalAnomalies.Should().Be(0); report.HitRate.Should().BeNull(); report.Resolved.Should().BeEmpty(); report.BySport.Should().BeEmpty(); report.BySeverity.Should().BeEmpty(); report.ByScoreBin.Should().BeEmpty(); } [Fact] public async Task Should_PartitionAnomalies_Into_ResolvedAndUnresolved() { var id1 = new EventId("11111111"); var id2 = new EventId("22222222"); _anomalies.ListAsync(Arg.Any()) .Returns(new[] { MakeAnomaly(id1, score: 0.65m), MakeAnomaly(id2, score: 0.40m), }.ToList().AsReadOnly()); _events.GetAsync(id1, Arg.Any()).Returns(MakeEvent(id1, 11)); _events.GetAsync(id2, Arg.Any()).Returns(MakeEvent(id2, 6)); // id1 has a result → resolved; id2 has no result → unresolved. _results.GetAsync(id1, Arg.Any()) .Returns(new EventResult(id1, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); _results.GetAsync(id2, Arg.Any()) .Returns((EventResult?)null); var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.TotalAnomalies.Should().Be(2); report.ResolvedCount.Should().Be(1); report.UnresolvedCount.Should().Be(1); report.HitCount.Should().Be(1, "id1's post-flip favourite (Side2) matched the actual winner"); report.MissCount.Should().Be(0); report.HitRate.Should().Be(1.0m); } [Fact] public async Task Should_ComputeHitRate_Across_MixedHitsAndMisses() { var ids = Enumerable.Range(1, 4) .Select(i => new EventId($"event-{i:00000000}")) .ToArray(); _anomalies.ListAsync(Arg.Any()) .Returns(ids.Select(id => MakeAnomaly(id, score: 0.55m)).ToList().AsReadOnly()); foreach (var id in ids) { _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, 11)); } // Three hits (Side2 wins), one miss (Side1 wins). _results.GetAsync(ids[0], Arg.Any()) .Returns(new EventResult(ids[0], 0, 2, Side.Side2, DateTimeOffset.UtcNow)); _results.GetAsync(ids[1], Arg.Any()) .Returns(new EventResult(ids[1], 0, 2, Side.Side2, DateTimeOffset.UtcNow)); _results.GetAsync(ids[2], Arg.Any()) .Returns(new EventResult(ids[2], 0, 2, Side.Side2, DateTimeOffset.UtcNow)); _results.GetAsync(ids[3], Arg.Any()) .Returns(new EventResult(ids[3], 2, 0, Side.Side1, DateTimeOffset.UtcNow)); var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.HitCount.Should().Be(3); report.MissCount.Should().Be(1); report.HitRate.Should().Be(0.75m); } [Fact] public async Task Should_BuildSeverityBuckets_Across_LowMediumHigh() { var idLow = new EventId("low000000"); var idMed = new EventId("med000000"); var idHigh = new EventId("high00000"); _anomalies.ListAsync(Arg.Any()) .Returns(new[] { MakeAnomaly(idLow, score: 0.35m), MakeAnomaly(idMed, score: 0.50m), MakeAnomaly(idHigh, score: 0.75m), }.ToList().AsReadOnly()); foreach (var id in new[] { idLow, idMed, idHigh }) { _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, 11)); _results.GetAsync(id, Arg.Any()) .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); } var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.BySeverity.Should().HaveCount(3); report.BySeverity.Single(b => b.Key == OutcomeBucketKeys.SeverityLow).Total.Should().Be(1); report.BySeverity.Single(b => b.Key == OutcomeBucketKeys.SeverityMedium).Total.Should().Be(1); report.BySeverity.Single(b => b.Key == OutcomeBucketKeys.SeverityHigh).Total.Should().Be(1); } [Fact] public async Task Should_GroupBySport_When_AnomaliesSpanMultipleSports() { var idFb = new EventId("fb000000"); var idBb = new EventId("bb000000"); _anomalies.ListAsync(Arg.Any()) .Returns(new[] { MakeAnomaly(idFb, score: 0.55m), MakeAnomaly(idBb, score: 0.55m), }.ToList().AsReadOnly()); _events.GetAsync(idFb, Arg.Any()).Returns(MakeEvent(idFb, 11)); _events.GetAsync(idBb, Arg.Any()).Returns(MakeEvent(idBb, 6)); _results.GetAsync(idFb, Arg.Any()) .Returns(new EventResult(idFb, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); _results.GetAsync(idBb, Arg.Any()) .Returns(new EventResult(idBb, 2, 0, Side.Side1, DateTimeOffset.UtcNow)); var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.BySport.Select(b => b.Key) .Should().BeEquivalentTo(new[] { "Sport.6", "Sport.11" }); report.BySport.Single(b => b.Key == "Sport.11").HitRate.Should().Be(1.0m); report.BySport.Single(b => b.Key == "Sport.6").HitRate.Should().Be(0.0m); } [Fact] public async Task Should_BuildSevenScoreBins_With_CanonicalKeys() { var id = new EventId("score000"); _anomalies.ListAsync(Arg.Any()) .Returns(new[] { MakeAnomaly(id, score: 0.95m) }.ToList().AsReadOnly()); _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, 11)); _results.GetAsync(id, Arg.Any()) .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.ByScoreBin.Should().HaveCount(7, "default buckets cover [0.30, 1.00] in 0.10-wide bins"); report.ByScoreBin.Select(b => b.Key).Should().BeEquivalentTo( new[] { "Bin.0.30-0.40", "Bin.0.40-0.50", "Bin.0.50-0.60", "Bin.0.60-0.70", "Bin.0.70-0.80", "Bin.0.80-0.90", "Bin.0.90-1.00", }, options => options.WithStrictOrdering(), "the page reads these literals to render labels"); report.ByScoreBin.Last().Total.Should().Be(1, "score 0.95 should land in the [0.90, 1.00] bin"); } [Theory] [InlineData(0.30, "Bin.0.30-0.40")] [InlineData(0.40, "Bin.0.40-0.50")] [InlineData(0.5999, "Bin.0.50-0.60")] [InlineData(0.60, "Bin.0.60-0.70")] [InlineData(1.00, "Bin.0.90-1.00")] public async Task Should_PlaceScore_InCorrectBin_AtBoundary(double scoreDouble, string expectedKey) { var score = (decimal)scoreDouble; var id = new EventId("boundary"); _anomalies.ListAsync(Arg.Any()) .Returns(new[] { MakeAnomaly(id, score) }.ToList().AsReadOnly()); _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, 11)); _results.GetAsync(id, Arg.Any()) .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); var report = await CreateSut().ExecuteAsync(CancellationToken.None); var bin = report.ByScoreBin.Single(b => b.Total == 1); bin.Key.Should().Be(expectedKey); } [Fact] public async Task Should_ExtendScoreBinsBelow_When_DetectorThresholdIsLowered() { // Operator lowered Anomaly.OddsFlipThreshold to 0.10 → anomalies with // score 0.15 exist. The histogram must still account for them. var idLow = new EventId("lowscore"); var idHigh = new EventId("hicscore"); _anomalies.ListAsync(Arg.Any()) .Returns(new[] { MakeAnomaly(idLow, score: 0.15m), MakeAnomaly(idHigh, score: 0.85m), }.ToList().AsReadOnly()); foreach (var id in new[] { idLow, idHigh }) { _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, 11)); _results.GetAsync(id, Arg.Any()) .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); } var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.ByScoreBin.Sum(b => b.Total).Should().Be(report.ResolvedCount, "the histogram total must equal ResolvedCount regardless of detector tuning"); report.ByScoreBin.First().Key.Should().Be("Bin.0.10-0.20", "buckets are extended downward to include the lowest observed score"); } [Fact] public async Task Should_PopulateEventTitles_ForJoinedEvents() { var id = new EventId("title000"); _anomalies.ListAsync(Arg.Any()) .Returns(new[] { MakeAnomaly(id, 0.55m) }.ToList().AsReadOnly()); _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, 11)); _results.GetAsync(id, Arg.Any()) .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.EventTitles.Should().ContainKey(id); report.EventTitles[id].Should().Be("Team A vs Team B"); } [Fact] public async Task Should_BatchEventAndResultLoads_InsteadOfPerIdGetAsync() { // Regression guard for the N+1 fix: the use case must resolve events/results // via the batched GetManyAsync, never the per-id GetAsync in a loop. We stub // GetManyAsync directly (overriding the constructor bridge) so DidNotReceive() // on GetAsync is meaningful. var id1 = new EventId("11111111"); var id2 = new EventId("22222222"); _anomalies.ListAsync(Arg.Any()) .Returns(new[] { MakeAnomaly(id1, 0.55m), MakeAnomaly(id2, 0.55m) }.ToList().AsReadOnly()); _events.GetManyAsync(Arg.Any>(), Arg.Any()) .Returns(new Dictionary { [id1] = MakeEvent(id1, 11), [id2] = MakeEvent(id2, 6) }); _results.GetManyAsync(Arg.Any>(), Arg.Any()) .Returns(new Dictionary()); await CreateSut().ExecuteAsync(CancellationToken.None); await _events.Received(1) .GetManyAsync(Arg.Any>(), Arg.Any()); await _events.DidNotReceive() .GetAsync(Arg.Any(), Arg.Any()); await _results.Received(1) .GetManyAsync(Arg.Any>(), Arg.Any()); await _results.DidNotReceive() .GetAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task Should_HandleMissingEvent_By_OmittingFromSportBuckets() { var id = new EventId("orphan00"); _anomalies.ListAsync(Arg.Any()) .Returns(new[] { MakeAnomaly(id, score: 0.55m) }.ToList().AsReadOnly()); _events.GetAsync(id, Arg.Any()).Returns((Event?)null); _results.GetAsync(id, Arg.Any()) .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); var report = await CreateSut().ExecuteAsync(CancellationToken.None); report.Resolved.Should().HaveCount(1, "orphan anomalies are still evaluated for hit/miss"); report.BySport.Should().BeEmpty( "missing event metadata excludes the row from sport breakdown"); } }