using FluentAssertions; using Marathon.Domain.AnomalyDetection; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; namespace Marathon.Domain.Tests.AnomalyDetection; public sealed class SuspensionFreezeDetectorTests { private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); private static readonly DateTimeOffset BaseTime = new(2026, 5, 10, 18, 0, 0, MoscowOffset); private static readonly EventId Event = new("26000002"); // suspension gap 60s, freeze threshold 0.05, min 3 snapshots. private static SuspensionFreezeDetector CreateSut() => new(60, 0.05m, 3); private static OddsSnapshot Live(int seconds, decimal r1, decimal r2) => new(Event, BaseTime.AddSeconds(seconds), OddsSource.Live, new List { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(r1)), new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(r2)), }); [Fact] public void Should_FlagFreeze_When_SuspensionResumesUnchanged() { // 90s gap between the 2nd and 3rd snapshot; line resumes identical. var snapshots = new[] { Live(0, 1.5m, 3.0m), Live(30, 1.5m, 3.0m), Live(120, 1.5m, 3.0m), // 90s gap, unchanged Live(150, 1.5m, 3.0m), }; var result = CreateSut().Detect(Event, snapshots); result.Should().ContainSingle(); result[0].Kind.Should().Be(AnomalyKind.SuspensionFreeze); result[0].Score.Should().Be(1.0m, "an unchanged line is a complete freeze"); } [Fact] public void Should_NotFlag_When_FlipAcrossSuspension() { // Favourite changes across the gap — that is the SuspensionFlip detector's job. var snapshots = new[] { Live(0, 1.3m, 4.0m), Live(30, 1.3m, 4.0m), Live(120, 4.0m, 1.3m), // 90s gap, flipped Live(150, 4.0m, 1.3m), }; CreateSut().Detect(Event, snapshots).Should().BeEmpty(); } [Fact] public void Should_NotFlag_When_NoSuspensionGap() { // Continuous 30s steps — no gap exceeds the 60s suspension threshold. var snapshots = new[] { Live(0, 1.5m, 3.0m), Live(30, 1.5m, 3.0m), Live(60, 1.5m, 3.0m), }; CreateSut().Detect(Event, snapshots).Should().BeEmpty(); } [Fact] public void Should_ReturnEmpty_When_FewerThanMinSnapshots() { var snapshots = new[] { Live(0, 1.5m, 3.0m), Live(120, 1.5m, 3.0m) }; CreateSut().Detect(Event, snapshots).Should().BeEmpty(); } [Fact] public void Should_EmitParseableEvidence_For_DetectedFreeze() { var snapshots = new[] { Live(0, 1.5m, 3.0m), Live(30, 1.5m, 3.0m), Live(120, 1.5m, 3.0m), }; var anomaly = CreateSut().Detect(Event, snapshots).First(); AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var data).Should().BeTrue(); data.PreSuspension.Favourite.Should().Be(data.PostSuspension.Favourite, "the favourite is unchanged in a freeze"); } [Fact] public void Should_Throw_When_FreezeThresholdOutOfRange() { var act = () => new SuspensionFreezeDetector(60, 0m, 3); act.Should().Throw(); } }