using Application.Abstractions.Data; using Application.Helpers; using Domain.Entities.Forum.Boards; using Domain.Entities.Forum.Posts; using Domain.Entities.Forum.Posts.ValueObject; using Domain.Entities.Members; using Domain.Entities.Stocks; using Domain.Entities.Stocks.ValueObject; using Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace Application.Tests; /// /// 예측 채점 배치 통합 검증 (D2 M4 예측 채점+트랙레코드, d2 §③·§⑥) — PredictionSettlementEngine. /// Up/Down 채점, 동가=Miss, TargetPrice, 시세없음/상폐 Void, 삭제글도 채점, /// DueDate 당일 없을 때 직전 거래일 종가 대체, 트랙레코드 재집계·멱등을 LocalDB(antooza_test)로 검증한다. /// 공유 DB 이므로 종목코드는 96xxxx 고범위 + GUID 회원으로 격리한다. /// [TestClass] public sealed class PredictionSettlementTests { private static Func Factory => () => TestDb.Create(); private static int _codeSeq = 960000; /// 테스트 간 고유 6자리 종목코드. private static string NextCode() => System.Threading.Interlocked.Increment(ref _codeSeq).ToString("D6"); private static async Task CreateStockAsync(AppDbContext db, string code, bool active = true, TradingStatus status = TradingStatus.Normal) { var stock = Stock.Create(code, $"테스트종목{code}", StockMarket.KOSPI, new DateOnly(2020, 1, 2)); if (!active) { stock.MarkDelisted(new DateOnly(2026, 1, 2)); } if (status != TradingStatus.Normal) { stock.SetTradingStatus(status); } await db.Stock.AddAsync(stock); await db.SaveChangesAsync(default); return stock; } private static async Task AddCloseAsync(AppDbContext db, int stockID, DateOnly date, int close) { await db.StockDailyPrice.AddAsync(StockDailyPrice.Create(stockID, date, close, close, close, close, 1000L, 1_000_000L, 0, 0m)); await db.SaveChangesAsync(default); } /// 회원 + stock 보드 Post 1건 생성 후 ID 반환. private static async Task<(int MemberID, int PostID)> CreateMemberWithPostAsync(AppDbContext db, string code, bool deleted = false) { var member = Member.Create($"pred-{Guid.NewGuid():N}@antooza.test"); await db.Member.AddAsync(member); await db.SaveChangesAsync(default); var group = new BoardGroup { Code = $"g{Guid.NewGuid():N}"[..12], Name = "테스트그룹" }; await db.BoardGroup.AddAsync(group); await db.SaveChangesAsync(default); var board = new Board { BoardGroupID = group.ID, Code = $"b{Guid.NewGuid():N}"[..12], Name = "테스트보드", IsActive = true }; await db.Board.AddAsync(board); await db.SaveChangesAsync(default); var post = new Post { BoardID = board.ID, MemberID = member.ID, StockCode = code, Subject = "예측글", Content = "본문", Name = member.Name, SID = member.SID, Email = member.Email, IsDeleted = deleted }; await db.Post.AddAsync(post); await db.SaveChangesAsync(default); return (member.ID, post.ID); } private static async Task AddPredictionAsync(AppDbContext db, int postID, int memberID, string code, PredictionDirection dir, decimal basePrice, DateOnly dueDate, decimal? target = null, short horizon = 5) { await db.PostPrediction.AddAsync(PostPrediction.Create(postID, memberID, code, dir, basePrice, horizon, dueDate, target)); await db.SaveChangesAsync(default); } // ── 방향 채점 ────────────────────────────────────────────── [TestMethod] public async Task Settle_Up_Hit_WhenCloseAboveBase() { var code = NextCode(); var due = new DateOnly(2026, 6, 10); int postID; int stockID; using (var db = TestDb.Create()) { var stock = await CreateStockAsync(db, code); stockID = stock.ID; (var memberID, postID) = await CreateMemberWithPostAsync(db, code); await AddCloseAsync(db, stockID, due, close: 11000); await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due); } using (var db = TestDb.Create()) { var (result, _) = await PredictionSettlementEngine.RunAsync(db, due, default); Assert.AreEqual(1, result.Considered); Assert.AreEqual(1, result.Hits); } using (var v = TestDb.Create()) { var p = await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID); Assert.AreEqual(PredictionStatus.Hit, p.Status); Assert.AreEqual(11000m, p.SettledPrice); Assert.IsNotNull(p.SettledAt); } } [TestMethod] public async Task Settle_Up_Miss_WhenCloseEqualBase() { // 동가 = Miss var code = NextCode(); var due = new DateOnly(2026, 6, 10); int postID; using (var db = TestDb.Create()) { var stock = await CreateStockAsync(db, code); (var memberID, postID) = await CreateMemberWithPostAsync(db, code); await AddCloseAsync(db, stock.ID, due, close: 10000); await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due); } using (var db = TestDb.Create()) { var (result, _) = await PredictionSettlementEngine.RunAsync(db, due, default); Assert.AreEqual(1, result.Misses); } using var v = TestDb.Create(); Assert.AreEqual(PredictionStatus.Miss, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status); } [TestMethod] public async Task Settle_Down_Hit_WhenCloseBelowBase() { var code = NextCode(); var due = new DateOnly(2026, 6, 11); int postID; using (var db = TestDb.Create()) { var stock = await CreateStockAsync(db, code); (var memberID, postID) = await CreateMemberWithPostAsync(db, code); await AddCloseAsync(db, stock.ID, due, close: 9000); await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Down, basePrice: 10000m, dueDate: due); } using (var db = TestDb.Create()) { await PredictionSettlementEngine.RunAsync(db, due, default); } using var v = TestDb.Create(); Assert.AreEqual(PredictionStatus.Hit, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status); } [TestMethod] public async Task Settle_Down_Miss_WhenCloseAboveBase() { var code = NextCode(); var due = new DateOnly(2026, 6, 11); int postID; using (var db = TestDb.Create()) { var stock = await CreateStockAsync(db, code); (var memberID, postID) = await CreateMemberWithPostAsync(db, code); await AddCloseAsync(db, stock.ID, due, close: 11000); await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Down, basePrice: 10000m, dueDate: due); } using (var db = TestDb.Create()) { await PredictionSettlementEngine.RunAsync(db, due, default); } using var v = TestDb.Create(); Assert.AreEqual(PredictionStatus.Miss, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status); } [TestMethod] public async Task Settle_WithTarget_Hit_WhenReached() { var code = NextCode(); var due = new DateOnly(2026, 6, 12); int postID; using (var db = TestDb.Create()) { var stock = await CreateStockAsync(db, code); (var memberID, postID) = await CreateMemberWithPostAsync(db, code); await AddCloseAsync(db, stock.ID, due, close: 12500); await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due, target: 12000m); } using (var db = TestDb.Create()) { await PredictionSettlementEngine.RunAsync(db, due, default); } using var v = TestDb.Create(); Assert.AreEqual(PredictionStatus.Hit, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status); } // ── Void ────────────────────────────────────────────────── [TestMethod] public async Task Settle_Void_WhenNoPrice() { var code = NextCode(); var due = new DateOnly(2026, 6, 13); int postID; using (var db = TestDb.Create()) { await CreateStockAsync(db, code); // 시세 미시드 (var memberID, postID) = await CreateMemberWithPostAsync(db, code); await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due); } using (var db = TestDb.Create()) { var (result, _) = await PredictionSettlementEngine.RunAsync(db, due, default); Assert.AreEqual(1, result.Voids); } using var v = TestDb.Create(); var p = await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID); Assert.AreEqual(PredictionStatus.Void, p.Status); Assert.IsNull(p.SettledPrice, "Void 는 종가 미기록"); } [TestMethod] public async Task Settle_Void_WhenDelisted_EvenWithPrice() { var code = NextCode(); var due = new DateOnly(2026, 6, 13); int postID; using (var db = TestDb.Create()) { var stock = await CreateStockAsync(db, code, active: false); // 상폐 (var memberID, postID) = await CreateMemberWithPostAsync(db, code); await AddCloseAsync(db, stock.ID, due, close: 12000); // 종가 있어도 await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due); } using (var db = TestDb.Create()) { await PredictionSettlementEngine.RunAsync(db, due, default); } using var v = TestDb.Create(); Assert.AreEqual(PredictionStatus.Void, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status); } [TestMethod] public async Task Settle_Void_WhenTradingHalted() { var code = NextCode(); var due = new DateOnly(2026, 6, 13); int postID; using (var db = TestDb.Create()) { var stock = await CreateStockAsync(db, code, status: TradingStatus.Halted); // 거래정지 (var memberID, postID) = await CreateMemberWithPostAsync(db, code); await AddCloseAsync(db, stock.ID, due, close: 12000); await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due); } using (var db = TestDb.Create()) { await PredictionSettlementEngine.RunAsync(db, due, default); } using var v = TestDb.Create(); Assert.AreEqual(PredictionStatus.Void, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status); } // ── 삭제글 채점 + 직전거래일 대체 ────────────────────────── [TestMethod] public async Task Settle_DeletedPost_StillScored() { // 삭제 회피 어뷰징 차단 — IsDeleted 여도 채점된다 var code = NextCode(); var due = new DateOnly(2026, 6, 16); int postID; using (var db = TestDb.Create()) { var stock = await CreateStockAsync(db, code); (var memberID, postID) = await CreateMemberWithPostAsync(db, code, deleted: true); await AddCloseAsync(db, stock.ID, due, close: 11000); await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due); } using (var db = TestDb.Create()) { var (result, _) = await PredictionSettlementEngine.RunAsync(db, due, default); Assert.AreEqual(1, result.Considered, "삭제글도 채점 대상"); Assert.AreEqual(1, result.Hits); } using var v = TestDb.Create(); Assert.AreEqual(PredictionStatus.Hit, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status); } [TestMethod] public async Task Settle_UsesNearestPriorTradingDay_WhenDueDateMissing() { // DueDate 당일 시세 없음(휴장) → 직전 거래일 종가로 채점 var code = NextCode(); var due = new DateOnly(2026, 6, 17); // 당일 시세 없음 var prior = new DateOnly(2026, 6, 16); // 직전 거래일 종가 존재 int postID; using (var db = TestDb.Create()) { var stock = await CreateStockAsync(db, code); (var memberID, postID) = await CreateMemberWithPostAsync(db, code); await AddCloseAsync(db, stock.ID, prior, close: 11500); await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due); } using (var db = TestDb.Create()) { await PredictionSettlementEngine.RunAsync(db, due, default); } using var v = TestDb.Create(); var p = await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID); Assert.AreEqual(PredictionStatus.Hit, p.Status); Assert.AreEqual(11500m, p.SettledPrice, "직전 거래일 종가로 채점"); } [TestMethod] public async Task Settle_SkipsPendingWithFutureDueDate() { var code = NextCode(); var due = new DateOnly(2026, 6, 30); // 미래 int postID; using (var db = TestDb.Create()) { var stock = await CreateStockAsync(db, code); (var memberID, postID) = await CreateMemberWithPostAsync(db, code); await AddCloseAsync(db, stock.ID, new DateOnly(2026, 6, 20), close: 11000); await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due); } using (var db = TestDb.Create()) { var (result, _) = await PredictionSettlementEngine.RunAsync(db, new DateOnly(2026, 6, 20), default); Assert.AreEqual(0, result.Considered, "DueDate 가 asOf 이후면 미채점"); } using var v = TestDb.Create(); Assert.AreEqual(PredictionStatus.Pending, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status); } // ── 트랙레코드 재집계 ───────────────────────────────────── [TestMethod] public async Task Settle_RecomputesTrackRecord_HitRateAndStreak() { // 한 회원이 3건 예측: Hit, Miss, Hit (동일 due) → 표본 2? 아니: 3건 모두 방향 채점. var code = NextCode(); var due = new DateOnly(2026, 6, 18); int memberID; using (var db = TestDb.Create()) { var stock = await CreateStockAsync(db, code); await AddCloseAsync(db, stock.ID, due, close: 11000); // Up 이면 Hit, Down 이면 Miss var member = Member.Create($"pred-{Guid.NewGuid():N}@antooza.test"); await db.Member.AddAsync(member); await db.SaveChangesAsync(default); memberID = member.ID; var group = new BoardGroup { Code = $"g{Guid.NewGuid():N}"[..12], Name = "g" }; await db.BoardGroup.AddAsync(group); await db.SaveChangesAsync(default); var board = new Board { BoardGroupID = group.ID, Code = $"b{Guid.NewGuid():N}"[..12], Name = "b", IsActive = true }; await db.Board.AddAsync(board); await db.SaveChangesAsync(default); // 3 posts: Up(Hit), Down(Miss), Up(Hit) foreach (var dir in new[] { PredictionDirection.Up, PredictionDirection.Down, PredictionDirection.Up }) { var post = new Post { BoardID = board.ID, MemberID = memberID, StockCode = code, Subject = "s", Content = "c", Name = member.Name, SID = member.SID, Email = member.Email }; await db.Post.AddAsync(post); await db.SaveChangesAsync(default); await AddPredictionAsync(db, post.ID, memberID, code, dir, basePrice: 10000m, dueDate: due); } } using (var db = TestDb.Create()) { var (result, _) = await PredictionSettlementEngine.RunAsync(db, due, default); Assert.AreEqual(3, result.Considered); Assert.AreEqual(1, result.MembersRecomputed); } using var v = TestDb.Create(); var tr = await v.MemberTrackRecord.AsNoTracking().SingleAsync(c => c.MemberID == memberID); Assert.AreEqual(3, tr.Predictions); Assert.AreEqual(2, tr.Hits); Assert.AreEqual(1, tr.Misses); Assert.AreEqual(0, tr.Voids); Assert.AreEqual(66.67m, tr.HitRate, "2/3 = 66.67"); // ID 순 시퀀스 H(Up) M(Down) H(Up) → 최장 연속 Hit=1, 현재 연속(마지막 H)=1 Assert.AreEqual(1, tr.BestStreak); Assert.AreEqual(1, tr.CurrentStreak); } [TestMethod] public async Task Settle_Idempotent_ReRunDoesNotDoubleCount() { var code = NextCode(); var due = new DateOnly(2026, 6, 19); int memberID; int postID; using (var db = TestDb.Create()) { var stock = await CreateStockAsync(db, code); (memberID, postID) = await CreateMemberWithPostAsync(db, code); await AddCloseAsync(db, stock.ID, due, close: 11000); await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due); } using (var db = TestDb.Create()) { await PredictionSettlementEngine.RunAsync(db, due, default); } // 재실행 — 이미 확정된 예측은 다시 채점되지 않고 트랙레코드도 그대로 using (var db = TestDb.Create()) { var (result, _) = await PredictionSettlementEngine.RunAsync(db, due, default); Assert.AreEqual(0, result.Considered, "이미 채점된 예측은 재대상 아님"); } using var v = TestDb.Create(); var tr = await v.MemberTrackRecord.AsNoTracking().SingleAsync(c => c.MemberID == memberID); Assert.AreEqual(1, tr.Predictions); Assert.AreEqual(1, tr.Hits); } }