using FluentAssertions; using Marathon.Domain.AnomalyDetection; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; namespace Marathon.Domain.Tests.AnomalyDetection; /// /// Unit tests for covering the join with /// for hit / miss / unresolved verdicts. /// public sealed class AnomalyOutcomeEvaluatorTests { private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); private static readonly EventId DefaultEventId = new("12345678"); private const string ThreeWayFlipJson = """ { "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 static Anomaly MakeAnomaly(string evidenceJson, decimal score = 0.5m) => new( Id: Guid.NewGuid(), EventId: DefaultEventId, DetectedAt: new DateTimeOffset(2026, 5, 10, 18, 5, 0, MoscowOffset), Kind: AnomalyKind.SuspensionFlip, Score: score, EvidenceJson: evidenceJson); private static EventResult MakeResult(Side winner, int s1 = 1, int s2 = 1) => new(DefaultEventId, s1, s2, winner, DateTimeOffset.UtcNow); [Fact] public void Should_ReportHit_When_PostFlipFavourite_Wins() { // Post-flip favourite = Side2; result = Side2 wins → Hit. var anomaly = MakeAnomaly(ThreeWayFlipJson, score: 0.65m); var result = MakeResult(Side.Side2, s1: 0, s2: 2); var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(6), result); verdict.Outcome.Should().Be(AnomalyOutcomeKind.Hit); verdict.PreFlipFavourite.Should().Be(Side.Side1); verdict.PostFlipFavourite.Should().Be(Side.Side2); verdict.ActualWinner.Should().Be(Side.Side2); verdict.Sport!.Value.Should().Be(6); } [Fact] public void Should_ReportMiss_When_PostFlipFavourite_Loses() { // Post-flip favourite = Side2; result = Side1 wins → Miss (detector wrong). var anomaly = MakeAnomaly(ThreeWayFlipJson); var result = MakeResult(Side.Side1, s1: 2, s2: 0); var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(11), result); verdict.Outcome.Should().Be(AnomalyOutcomeKind.Miss); verdict.PostFlipFavourite.Should().Be(Side.Side2); verdict.ActualWinner.Should().Be(Side.Side1); } [Fact] public void Should_ReportUnresolved_When_KindIsNonDirectional() { // SuspensionFreeze is informational (the favourite did not change) — it must NOT be // graded hit/miss even when a result exists, or its base favourite-win rate would // pollute the calibration. Favourites are still surfaced for display. var anomaly = new Anomaly( Id: Guid.NewGuid(), EventId: DefaultEventId, DetectedAt: new DateTimeOffset(2026, 5, 10, 18, 5, 0, MoscowOffset), Kind: AnomalyKind.SuspensionFreeze, Score: 0.9m, EvidenceJson: ThreeWayFlipJson); var result = MakeResult(Side.Side2, s1: 0, s2: 2); var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(6), result); verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved); verdict.PostFlipFavourite.Should().Be(Side.Side2); } [Fact] public void Should_ReportMiss_When_DrawOccurred_AndPostFlipFavouriteIsNotDraw() { var anomaly = MakeAnomaly(ThreeWayFlipJson); var result = MakeResult(Side.Draw, s1: 1, s2: 1); var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, null, result); verdict.Outcome.Should().Be(AnomalyOutcomeKind.Miss); verdict.ActualWinner.Should().Be(Side.Draw); } [Fact] public void Should_ReportUnresolved_When_ResultIsNull() { var anomaly = MakeAnomaly(ThreeWayFlipJson); var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result: null); verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved); verdict.ActualWinner.Should().BeNull(); // Pre/post favourites still computed for display. verdict.PreFlipFavourite.Should().Be(Side.Side1); verdict.PostFlipFavourite.Should().Be(Side.Side2); } [Fact] public void Should_ReportUnresolved_When_EvidenceJsonIsMalformed() { var anomaly = MakeAnomaly("{malformed"); var result = MakeResult(Side.Side1); var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, null, result); verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved, "evidence cannot be parsed so we cannot judge the prediction"); verdict.PreFlipFavourite.Should().BeNull( "fabricated favourites would mislead any consumer that reads the unresolved branch"); verdict.PostFlipFavourite.Should().BeNull(); verdict.ActualWinner.Should().Be(Side.Side1, "the result side is still known and surfaced"); } [Fact] public void Should_ReportHit_For_TwoWayTennis_When_PostFlipFavouriteWins() { const string twoWayJson = """ { "suspensionGapSeconds": 75, "preSuspension": { "capturedAt": "2026-05-10T18:00:00+03:00", "p1": 0.70, "p2": 0.30, "rate1": 1.4, "rate2": 3.3 }, "postSuspension": { "capturedAt": "2026-05-10T18:01:30+03:00", "p1": 0.30, "p2": 0.70, "rate1": 3.3, "rate2": 1.4 } } """; var anomaly = MakeAnomaly(twoWayJson, score: 0.55m); var result = MakeResult(Side.Side2, s1: 0, s2: 2); var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result); verdict.Outcome.Should().Be(AnomalyOutcomeKind.Hit); verdict.PostFlipFavourite.Should().Be(Side.Side2); } [Fact] public void Should_ReportUnresolved_When_TwoWayMarket_Has_DrawWinner() { // Tennis cannot draw — if the result is Draw the data is inconsistent // with the evidence and we refuse to grade rather than silently miss-classify. const string twoWayJson = """ { "suspensionGapSeconds": 75, "preSuspension": { "capturedAt": "2026-05-10T18:00:00+03:00", "p1": 0.70, "p2": 0.30, "rate1": 1.4, "rate2": 3.3 }, "postSuspension": { "capturedAt": "2026-05-10T18:01:30+03:00", "p1": 0.30, "p2": 0.70, "rate1": 3.3, "rate2": 1.4 } } """; var anomaly = MakeAnomaly(twoWayJson, score: 0.55m); var result = MakeResult(Side.Draw); var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result); verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved); verdict.ActualWinner.Should().Be(Side.Draw); verdict.PostFlipFavourite.Should().Be(Side.Side2, "favourite is still computed for display, just not graded"); } [Fact] public void Should_Throw_When_AnomalyIsNull() { var act = () => AnomalyOutcomeEvaluator.Evaluate(null!, null, null); act.Should().Throw(); } }