using Application.Helpers; using Domain.Entities.Forum.Posts.ValueObject; using Domain.Entities.Members; namespace Application.Tests; /// /// 예측 채점 + 트랙레코드 집계 순수 로직 검증 (D2 M4 예측 채점+트랙레코드, d2 §③). /// PredictionScorer 는 DB 의존이 없어 인메모리로 규칙 경계를 검증한다: /// Up/Down 방향, 동가=Miss, TargetPrice 도달, Void(시세 없음/상폐/거래정지), /// 트랙레코드 적중률·스트릭·Tier 경계. /// [TestClass] public sealed class PredictionScorerTests { private static PredictionScorer.Input Dir(PredictionDirection dir, decimal basePrice, decimal? settled, bool tradable = true) => new(dir, basePrice, null, settled, tradable); private static PredictionScorer.Input Target(PredictionDirection dir, decimal basePrice, decimal target, decimal? settled, bool tradable = true) => new(dir, basePrice, target, settled, tradable); // ── 방향 채점 ────────────────────────────────────────────── [TestMethod] public void Up_SettledAboveBase_IsHit() { Assert.AreEqual(PredictionStatus.Hit, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, 10001m))); } [TestMethod] public void Up_SettledBelowBase_IsMiss() { Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, 9999m))); } [TestMethod] public void Up_SettledEqualBase_IsMiss() { // 동가(0% 변동) = Miss (d2 §③ 채점 규칙 v1) Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, 10000m))); } [TestMethod] public void Down_SettledBelowBase_IsHit() { Assert.AreEqual(PredictionStatus.Hit, PredictionScorer.Score(Dir(PredictionDirection.Down, 10000m, 9999m))); } [TestMethod] public void Down_SettledAboveBase_IsMiss() { Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Dir(PredictionDirection.Down, 10000m, 10001m))); } [TestMethod] public void Down_SettledEqualBase_IsMiss() { Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Dir(PredictionDirection.Down, 10000m, 10000m))); } // ── 목표가 채점 ───────────────────────────────────────────── [TestMethod] public void Up_WithTarget_ReachedExactly_IsHit() { // Up: 종가 ≥ 목표가면 Hit (경계 포함) Assert.AreEqual(PredictionStatus.Hit, PredictionScorer.Score(Target(PredictionDirection.Up, 10000m, 12000m, 12000m))); } [TestMethod] public void Up_WithTarget_BelowTarget_IsMiss() { Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Target(PredictionDirection.Up, 10000m, 12000m, 11999m))); } [TestMethod] public void Down_WithTarget_ReachedExactly_IsHit() { // Down: 종가 ≤ 목표가면 Hit Assert.AreEqual(PredictionStatus.Hit, PredictionScorer.Score(Target(PredictionDirection.Down, 10000m, 8000m, 8000m))); } [TestMethod] public void Down_WithTarget_AboveTarget_IsMiss() { Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Target(PredictionDirection.Down, 10000m, 8000m, 8001m))); } // ── Void ────────────────────────────────────────────────── [TestMethod] public void NoPrice_IsVoid() { Assert.AreEqual(PredictionStatus.Void, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, settled: null))); } [TestMethod] public void NotTradable_IsVoid_EvenWithPrice() { // 상폐/거래정지 → 종가가 있어도 Void Assert.AreEqual(PredictionStatus.Void, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, 12000m, tradable: false))); } // ── 트랙레코드 집계 ─────────────────────────────────────── [TestMethod] public void Aggregate_HitRate_ExcludesVoids() { // Hit 3, Miss 1, Void 2 → 표본=4, 적중률=75.00, Predictions=6 var agg = PredictionScorer.Aggregate([ PredictionStatus.Hit, PredictionStatus.Void, PredictionStatus.Hit, PredictionStatus.Miss, PredictionStatus.Void, PredictionStatus.Hit ]); Assert.AreEqual(6, agg.Predictions); Assert.AreEqual(3, agg.Hits); Assert.AreEqual(1, agg.Misses); Assert.AreEqual(2, agg.Voids); Assert.AreEqual(75.00m, agg.HitRate); } [TestMethod] public void Aggregate_Streaks_IgnoreVoids() { // 시간순: H H V H M H H → BestStreak=3(마지막 세 H 중 V 무시로 첫 H H + H = 3), Current=2(마지막 M 이후 H H) var agg = PredictionScorer.Aggregate([ PredictionStatus.Hit, PredictionStatus.Hit, PredictionStatus.Void, PredictionStatus.Hit, PredictionStatus.Miss, PredictionStatus.Hit, PredictionStatus.Hit ]); Assert.AreEqual(3, agg.BestStreak, "H H (V무시) H = 연속 3"); Assert.AreEqual(2, agg.CurrentStreak, "마지막 Miss 이후 H H"); } [TestMethod] public void Aggregate_CurrentStreak_ZeroWhenLastIsMiss() { var agg = PredictionScorer.Aggregate([PredictionStatus.Hit, PredictionStatus.Hit, PredictionStatus.Miss]); Assert.AreEqual(0, agg.CurrentStreak); Assert.AreEqual(2, agg.BestStreak); } [TestMethod] public void Aggregate_Empty_IsNoneTierZeroRate() { var agg = PredictionScorer.Aggregate([]); Assert.AreEqual(0, agg.Predictions); Assert.AreEqual(0m, agg.HitRate); Assert.AreEqual(TrackRecordTier.None, agg.Tier); } // ── Tier 경계 ───────────────────────────────────────────── [TestMethod] public void Tier_BelowMinSample_IsNone_EvenAt100Percent() { // 9건 전부 Hit → 100% 지만 표본 10 미만 → None var statuses = Enumerable.Repeat(PredictionStatus.Hit, 9).ToList(); var agg = PredictionScorer.Aggregate(statuses); Assert.AreEqual(100.00m, agg.HitRate); Assert.AreEqual(TrackRecordTier.None, agg.Tier); } [TestMethod] public void Tier_Bronze_At50Percent_With10Sample() { // 10건 중 5 Hit → 50% → Bronze var statuses = Enumerable.Repeat(PredictionStatus.Hit, 5).Concat(Enumerable.Repeat(PredictionStatus.Miss, 5)).ToList(); var agg = PredictionScorer.Aggregate(statuses); Assert.AreEqual(50.00m, agg.HitRate); Assert.AreEqual(TrackRecordTier.Bronze, agg.Tier); } [TestMethod] public void Tier_JustBelowBronze_IsNone() { // 20건 중 9 Hit = 45% → None (50 미만) var statuses = Enumerable.Repeat(PredictionStatus.Hit, 9).Concat(Enumerable.Repeat(PredictionStatus.Miss, 11)).ToList(); var agg = PredictionScorer.Aggregate(statuses); Assert.AreEqual(45.00m, agg.HitRate); Assert.AreEqual(TrackRecordTier.None, agg.Tier); } [TestMethod] public void Tier_Silver_At60Percent() { var statuses = Enumerable.Repeat(PredictionStatus.Hit, 6).Concat(Enumerable.Repeat(PredictionStatus.Miss, 4)).ToList(); var agg = PredictionScorer.Aggregate(statuses); Assert.AreEqual(60.00m, agg.HitRate); Assert.AreEqual(TrackRecordTier.Silver, agg.Tier); } [TestMethod] public void Tier_Gold_At70Percent() { var statuses = Enumerable.Repeat(PredictionStatus.Hit, 7).Concat(Enumerable.Repeat(PredictionStatus.Miss, 3)).ToList(); var agg = PredictionScorer.Aggregate(statuses); Assert.AreEqual(70.00m, agg.HitRate); Assert.AreEqual(TrackRecordTier.Gold, agg.Tier); } [TestMethod] public void Tier_GoldAnt_Requires80PercentAnd30Sample() { // 20건 중 16 Hit = 80% 지만 표본 20 < 30 → Gold (황금개미 아님) var small = Enumerable.Repeat(PredictionStatus.Hit, 16).Concat(Enumerable.Repeat(PredictionStatus.Miss, 4)).ToList(); var smallAgg = PredictionScorer.Aggregate(small); Assert.AreEqual(80.00m, smallAgg.HitRate); Assert.AreEqual(TrackRecordTier.Gold, smallAgg.Tier, "표본 30 미만이면 황금개미 아님"); // 30건 중 24 Hit = 80% + 표본 30 → GoldAnt var big = Enumerable.Repeat(PredictionStatus.Hit, 24).Concat(Enumerable.Repeat(PredictionStatus.Miss, 6)).ToList(); var bigAgg = PredictionScorer.Aggregate(big); Assert.AreEqual(80.00m, bigAgg.HitRate); Assert.AreEqual(TrackRecordTier.GoldAnt, bigAgg.Tier); } }