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);
}
}