From a74bf74d130874c4c0edc5cd277c2738d2097ad9 Mon Sep 17 00:00:00 2001 From: art Date: Wed, 24 Jun 2026 12:40:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(jam):=20W2-6=20=EC=8B=9C=EC=83=81=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=E2=80=94=203=ED=8A=B8=EB=9E=99=20?= =?UTF-8?q?=EA=B0=9C=EB=B3=84=EC=88=98=EC=83=81=20+=20=EA=B0=80=EC=A4=91?= =?UTF-8?q?=20GRAND=20+=20CLOSED=20=ED=99=95=EC=A0=95=20=EB=A9=B1=EB=93=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JamAwardService: 3트랙(JUDGE/USER_RATING/POPULAR) 개별수상 + GRAND 가중집계. @Transactional recompute = deleteByJamTrack → 재insert(멱등, 두 번 돌려도 결과 동일) - NULL/임계 제외: JUDGE weightedTotal NULL 제외, USER_RATING review_count>=3 임계·NULLS LAST, POPULAR 득표>0. GRAND 가용 트랙만 가중 정규화(RankScores) - 소비: jam_score_stats VIEW(심사) + game_review_stats(유저평점, JamReviewRatingMapper) + jam_votes(인기, JamVotesMapper.listCountsByJam 재사용). 신규 DDL 0(W2-3 jam_awards 소비) - §33 인용 alias: 집계 매퍼(JamReviewRatingMapper) camelCase AS "..." 인용, JamAwardsMapper 일반 POJO 비인용 - JamAwardAdminController: CLOSED 게이트(422) + CSRF(403) + GAME_JAM_MANAGE. JamAwardController 공개 결과. jam-results.jsp. JamController +생성자 인자(AW-DETAIL) - BibimbapApplicationTests @MockBean 2매퍼(JamAwardsMapper/JamReviewRatingMapper) 검증: ./mvnw -o test 190/190 GREEN(신규 19: JamAwardServiceTest 7·JamAwardAdminControllerTest 7·JamAwardControllerTest 5, 회귀 0 — JamControllerTest 14/14 6arg 후도 GREEN). L2 contract PASS — 멱등 recompute(DELETE→INSERT 2회 count 불변·md5 동일)·3트랙 임계/NULL 제외·alias camelCase 보존·GRAND 가중. 집합전수 AC-T1~6 PASS. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controller/JamAwardAdminController.java | 87 +++++ .../controller/JamAwardController.java | 46 +++ .../bibimbap/controller/JamController.java | 7 +- .../bibimbap/data/JamAwardData.java | 111 +++++++ .../pandoli365/bibimbap/jam/AwardTrack.java | 31 ++ .../bibimbap/jam/JamAwardService.java | 207 ++++++++++++ .../pandoli365/bibimbap/jam/RankScores.java | 84 +++++ .../bibimbap/mapper/JamAwardsMapper.java | 76 +++++ .../mapper/JamReviewRatingMapper.java | 39 +++ src/main/webapp/WEB-INF/views/jam-detail.jsp | 40 +++ src/main/webapp/WEB-INF/views/jam-results.jsp | 301 ++++++++++++++++++ .../bibimbap/BibimbapApplicationTests.java | 8 + .../JamAwardAdminControllerTest.java | 239 ++++++++++++++ .../controller/JamAwardControllerTest.java | 156 +++++++++ .../controller/JamControllerTest.java | 6 +- .../bibimbap/jam/JamAwardServiceTest.java | 297 +++++++++++++++++ 16 files changed, 1733 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/pandoli365/bibimbap/controller/JamAwardAdminController.java create mode 100644 src/main/java/com/pandoli365/bibimbap/controller/JamAwardController.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/JamAwardData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/jam/AwardTrack.java create mode 100644 src/main/java/com/pandoli365/bibimbap/jam/JamAwardService.java create mode 100644 src/main/java/com/pandoli365/bibimbap/jam/RankScores.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/JamAwardsMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/JamReviewRatingMapper.java create mode 100644 src/main/webapp/WEB-INF/views/jam-results.jsp create mode 100644 src/test/java/com/pandoli365/bibimbap/controller/JamAwardAdminControllerTest.java create mode 100644 src/test/java/com/pandoli365/bibimbap/controller/JamAwardControllerTest.java create mode 100644 src/test/java/com/pandoli365/bibimbap/jam/JamAwardServiceTest.java diff --git a/src/main/java/com/pandoli365/bibimbap/controller/JamAwardAdminController.java b/src/main/java/com/pandoli365/bibimbap/controller/JamAwardAdminController.java new file mode 100644 index 0000000..1e4e6ed --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/JamAwardAdminController.java @@ -0,0 +1,87 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.JamData; +import com.pandoli365.bibimbap.jam.JamAwardService; +import com.pandoli365.bibimbap.mapper.JamsMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; +import com.pandoli365.bibimbap.security.PermissionGate; +import com.pandoli365.bibimbap.security.PermissionKeys; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.time.OffsetDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@Controller +public class JamAwardAdminController { + + private final JamsMapper jamsMapper; + private final PermissionGate gate; + private final JamAwardService jamAwardService; + + public JamAwardAdminController(JamsMapper jamsMapper, + PermissionGate gate, + JamAwardService jamAwardService) { + this.jamsMapper = jamsMapper; + this.gate = gate; + this.jamAwardService = jamAwardService; + } + + @PostMapping("/admin/jams/{jamId}/awards/compute") + @ResponseBody + public ResponseEntity> computeAwards( + @PathVariable("jamId") long jamId, + HttpServletRequest request, + HttpSession session) { + ResponseEntity> denied = requireJamManage(session); + if (denied != null) { + return denied; + } + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + JamData jam = jamsMapper.getById(jamId); + if (jam == null) { + return response(HttpStatus.NOT_FOUND, "게임잼을 찾을 수 없습니다."); + } + + boolean evalEnded = jam.getEvalEndAt() != null && OffsetDateTime.now().isAfter(jam.getEvalEndAt()); + boolean computable = "CLOSED".equals(jam.getStatus()) || evalEnded; + if (!computable) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "시상 산정 가능 상태가 아닙니다."); + } + + Map awardCounts = jamAwardService.recompute(jam.getId()); + + Map body = new LinkedHashMap<>(); + body.put("status", HttpStatus.OK.value()); + body.put("message", "시상을 산정했습니다."); + body.put("jamId", jam.getId()); + body.put("awardCounts", awardCounts); + return ResponseEntity.ok(body); + } + + private ResponseEntity> requireJamManage(HttpSession session) { + if (!gate.isAuthenticated(session)) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + if (!gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())) { + return response(HttpStatus.FORBIDDEN, "권한이 없습니다."); + } + return null; + } + + private ResponseEntity> response(HttpStatus status, String message) { + Map body = new LinkedHashMap<>(); + body.put("status", status.value()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/JamAwardController.java b/src/main/java/com/pandoli365/bibimbap/controller/JamAwardController.java new file mode 100644 index 0000000..7d41d0f --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/JamAwardController.java @@ -0,0 +1,46 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.JamAwardData; +import com.pandoli365.bibimbap.data.JamData; +import com.pandoli365.bibimbap.mapper.JamAwardsMapper; +import com.pandoli365.bibimbap.mapper.JamsMapper; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Controller +public class JamAwardController { + + private final JamsMapper jamsMapper; + private final JamAwardsMapper jamAwardsMapper; + + public JamAwardController(JamsMapper jamsMapper, JamAwardsMapper jamAwardsMapper) { + this.jamsMapper = jamsMapper; + this.jamAwardsMapper = jamAwardsMapper; + } + + @GetMapping("/jams/{slug}/results") + public String results(@PathVariable("slug") String slug, Model model) { + JamData jam = jamsMapper.getBySlug(slug); + if (jam == null || Boolean.FALSE.equals(jam.getIsVisible())) { + return "redirect:/jams"; + } + + List awards = jamAwardsMapper.listByJamWithGame(jam.getId()); + Map> byTrack = new LinkedHashMap<>(); + for (JamAwardData award : awards) { + byTrack.computeIfAbsent(award.getAwardTrack(), track -> new ArrayList<>()).add(award); + } + + model.addAttribute("jam", jam); + model.addAttribute("byTrack", byTrack); + model.addAttribute("grand", byTrack.getOrDefault("GRAND", List.of())); + return "jam-results"; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/JamController.java b/src/main/java/com/pandoli365/bibimbap/controller/JamController.java index 96e5d25..514edd2 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/JamController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/JamController.java @@ -5,6 +5,7 @@ import com.pandoli365.bibimbap.data.JamData; import com.pandoli365.bibimbap.data.JamEntryData; import com.pandoli365.bibimbap.data.JamTeamData; import com.pandoli365.bibimbap.mapper.GamesMapper; +import com.pandoli365.bibimbap.mapper.JamAwardsMapper; import com.pandoli365.bibimbap.mapper.JamEntriesMapper; import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper; import com.pandoli365.bibimbap.mapper.JamTeamsMapper; @@ -38,19 +39,22 @@ public class JamController { private final JamTeamsMapper jamTeamsMapper; private final JamTeamMembersMapper jamTeamMembersMapper; private final GamesMapper gamesMapper; + private final JamAwardsMapper jamAwardsMapper; public JamController( JamsMapper jamsMapper, JamEntriesMapper jamEntriesMapper, JamTeamsMapper jamTeamsMapper, JamTeamMembersMapper jamTeamMembersMapper, - GamesMapper gamesMapper + GamesMapper gamesMapper, + JamAwardsMapper jamAwardsMapper ) { this.jamsMapper = jamsMapper; this.jamEntriesMapper = jamEntriesMapper; this.jamTeamsMapper = jamTeamsMapper; this.jamTeamMembersMapper = jamTeamMembersMapper; this.gamesMapper = gamesMapper; + this.jamAwardsMapper = jamAwardsMapper; } @GetMapping("/jams") @@ -95,6 +99,7 @@ public class JamController { model.addAttribute("jam", jam); model.addAttribute("entries", jamEntriesMapper.listByJam(jam.getId())); model.addAttribute("teams", jamTeamsMapper.listByJam(jam.getId())); + model.addAttribute("awardsSummary", jamAwardsMapper.listByJamWithGame(jam.getId())); model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession())); return "jam-detail"; } diff --git a/src/main/java/com/pandoli365/bibimbap/data/JamAwardData.java b/src/main/java/com/pandoli365/bibimbap/data/JamAwardData.java new file mode 100644 index 0000000..1493176 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/JamAwardData.java @@ -0,0 +1,111 @@ +package com.pandoli365.bibimbap.data; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +/** + * jam_awards 행 POJO (W2-3 동결 스키마 소비 — docs/jam-eval-ddl.sql §4). + * + *

핵심 컬럼(jamId/gameId/awardTrack/rank/scoreValue/computedAt)에 더해 + * 결과 페이지 표시용 JOIN 필드(gameName/thumbnailUrl/entrantName)를 보유한다. + * 표시 필드는 listByJamWithGame(JOIN games + jam_entries) 조회에서만 채워지고, + * INSERT 시에는 핵심 컬럼만 사용한다(표시 필드 write 0). + * + *

POJO 직접 매핑이므로 매퍼 alias 는 snake→camel 비인용(scoreValue) 사용 — §33 + * 큰따옴표 규칙은 집계 VIEW(Map resultType) 소비에만 적용. 본 POJO 는 일반 테이블 매핑. + */ +public class JamAwardData { + + private Long id; + private Long jamId; + private Long gameId; + private String awardTrack; + private Integer rank; + private BigDecimal scoreValue; + private OffsetDateTime computedAt; + + // 결과 페이지 표시용 JOIN 필드(listByJamWithGame 전용 — INSERT 미사용) + private String gameName; + private String thumbnailUrl; + private String entrantName; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getJamId() { + return jamId; + } + + public void setJamId(Long jamId) { + this.jamId = jamId; + } + + public Long getGameId() { + return gameId; + } + + public void setGameId(Long gameId) { + this.gameId = gameId; + } + + public String getAwardTrack() { + return awardTrack; + } + + public void setAwardTrack(String awardTrack) { + this.awardTrack = awardTrack; + } + + public Integer getRank() { + return rank; + } + + public void setRank(Integer rank) { + this.rank = rank; + } + + public BigDecimal getScoreValue() { + return scoreValue; + } + + public void setScoreValue(BigDecimal scoreValue) { + this.scoreValue = scoreValue; + } + + public OffsetDateTime getComputedAt() { + return computedAt; + } + + public void setComputedAt(OffsetDateTime computedAt) { + this.computedAt = computedAt; + } + + public String getGameName() { + return gameName; + } + + public void setGameName(String gameName) { + this.gameName = gameName; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } + + public void setThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } + + public String getEntrantName() { + return entrantName; + } + + public void setEntrantName(String entrantName) { + this.entrantName = entrantName; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/jam/AwardTrack.java b/src/main/java/com/pandoli365/bibimbap/jam/AwardTrack.java new file mode 100644 index 0000000..029604f --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/jam/AwardTrack.java @@ -0,0 +1,31 @@ +package com.pandoli365.bibimbap.jam; + +/** + * 시상 트랙. W2-3 동결 jam_awards.award_track CHECK IN ('JUDGE','USER_RATING','POPULAR','GRAND') + * 4값과 1:1 정합(docs/jam-eval-ddl.sql §4). 트랙 추가/삭제는 W2-3 동결 스키마 동시 변경 필요. + * + *

    + *
  • JUDGE — 심사 가중 종합점수(jam_score_stats.weighted_total)
  • + *
  • USER_RATING — 유저 평균 별점(game_review_stats.avg_rating, review_count>=3 임계)
  • + *
  • POPULAR — 인기투표 득표수(jam_votes count)
  • + *
  • GRAND — 3트랙 순위점수 정규화 가중합 종합
  • + *
+ */ +public enum AwardTrack { + JUDGE, + USER_RATING, + POPULAR, + GRAND; + + public static boolean isValid(String key) { + if (key == null) { + return false; + } + try { + valueOf(key); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/jam/JamAwardService.java b/src/main/java/com/pandoli365/bibimbap/jam/JamAwardService.java new file mode 100644 index 0000000..6fa9b7e --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/jam/JamAwardService.java @@ -0,0 +1,207 @@ +package com.pandoli365.bibimbap.jam; + +import com.pandoli365.bibimbap.data.JamAwardData; +import com.pandoli365.bibimbap.mapper.JamAwardsMapper; +import com.pandoli365.bibimbap.mapper.JamReviewRatingMapper; +import com.pandoli365.bibimbap.mapper.JamScoreStatsMapper; +import com.pandoli365.bibimbap.mapper.JamVotesMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 시상 산정 코어 — 3트랙 독립 랭킹(JUDGE/USER_RATING/POPULAR) + GRAND 종합(순위점수 + * 정규화 가중합). W2-3 동결 jam_awards 소비, 4개 소스(jam_score_stats/game_review_stats/ + * jam_votes/jam_entries)는 SELECT 만(단방향 G4/AC-13). + * + *

멱등 재산정: {@link #recompute}는 4트랙 deleteByJamTrack 후 재INSERT 를 단일 + * @Transactional 경계에서 수행 — 두 번 돌려도 결과 동일, 부분 실패 시 트랙 비는 상태 회피. + * + *

NULL/미달 트랙 제외(A4, 부당 0점 회피): + *

    + *
  • JUDGE — weighted_total NULL(채점 0) 행 제외(매퍼 NULLS LAST 후 본 코드 필터)
  • + *
  • USER_RATING — review_count>=3 임계(매퍼 WHERE 에서 제외)
  • + *
  • POPULAR — 득표>0 출품작만(매퍼 GROUP BY 결과; 0표는 행 없음)
  • + *
  • GRAND — 최소 1트랙 가용 출품작. 가용 트랙만 분자·분모에 포함
  • + *
+ */ +@Service +public class JamAwardService { + + /** 트랙 균등 가중치(A3 상수). 잼별 가변은 후속(design concern 4). */ + private static final double WEIGHT_JUDGE = 1.0 / 3.0; + private static final double WEIGHT_USER_RATING = 1.0 / 3.0; + private static final double WEIGHT_POPULAR = 1.0 / 3.0; + + private final JamAwardsMapper jamAwardsMapper; + private final JamScoreStatsMapper jamScoreStatsMapper; + private final JamReviewRatingMapper jamReviewRatingMapper; + private final JamVotesMapper jamVotesMapper; + + public JamAwardService(JamAwardsMapper jamAwardsMapper, + JamScoreStatsMapper jamScoreStatsMapper, + JamReviewRatingMapper jamReviewRatingMapper, + JamVotesMapper jamVotesMapper) { + this.jamAwardsMapper = jamAwardsMapper; + this.jamScoreStatsMapper = jamScoreStatsMapper; + this.jamReviewRatingMapper = jamReviewRatingMapper; + this.jamVotesMapper = jamVotesMapper; + } + + /** + * 산정/재산정(멱등). 4트랙 초기화 후 재기록. 반환=트랙별 수상 행수. + * + * @param jamId 산정 대상 잼 + * @return {JUDGE:n, USER_RATING:n, POPULAR:n, GRAND:n} 트랙별 수상 행수 + */ + @Transactional + public Map recompute(long jamId) { + for (AwardTrack track : AwardTrack.values()) { + jamAwardsMapper.deleteByJamTrack(jamId, track.name()); + } + + // 트랙별 모집단(gameId -> raw 점수). NULL/미달 제외 후의 수상권 모집단. + Map judgePop = trackPopulation( + jamScoreStatsMapper.listStatsByJam(jamId), "gameId", "weightedTotal"); + Map ratingPop = trackPopulation( + jamReviewRatingMapper.listAvgByJam(jamId), "gameId", "avgRating"); + Map popularPop = trackPopulation( + jamVotesMapper.listCountsByJam(jamId), "gameId", "voteCount"); + + Map counts = new LinkedHashMap<>(); + counts.put(AwardTrack.JUDGE.name(), insertTrack(jamId, AwardTrack.JUDGE, judgePop)); + counts.put(AwardTrack.USER_RATING.name(), insertTrack(jamId, AwardTrack.USER_RATING, ratingPop)); + counts.put(AwardTrack.POPULAR.name(), insertTrack(jamId, AwardTrack.POPULAR, popularPop)); + counts.put(AwardTrack.GRAND.name(), insertGrand(jamId, judgePop, ratingPop, popularPop)); + return counts; + } + + /** + * 단일 트랙 산정: 모집단을 raw 점수 DESC 로 랭크(competition) 후 jam_awards 기록. + * score_value = 트랙 raw 점수. + */ + private int insertTrack(long jamId, AwardTrack track, Map population) { + if (population.isEmpty()) { + return 0; + } + List games = new ArrayList<>(population.keySet()); + Comparator byScore = Comparator.comparingDouble( + (Long g) -> population.get(g)).reversed(); + Comparator byOrder = byScore.thenComparing(Comparator.naturalOrder()); // game_id ASC tiebreak + Map ranks = RankScores.standardCompetitionRank(games, byOrder, byScore); + + int n = 0; + for (Map.Entry e : ranks.entrySet()) { + jamAwardsMapper.insert(award(jamId, e.getKey(), track, e.getValue(), + BigDecimal.valueOf(population.get(e.getKey())))); + n++; + } + return n; + } + + /** + * GRAND 종합: 각 트랙 rankScore (N-rank+1)/N 의 가용트랙 가중평균. + * grand = Σ(rankScore_track × weight_track) / Σ(weight_track 가용) ∈ [1/N, 1]. + * 모집단 = 최소 1트랙 가용 출품작 union. 가용 트랙만 분자·분모 포함(부당 0점 회피). + */ + private int insertGrand(long jamId, + Map judgePop, + Map ratingPop, + Map popularPop) { + Map judgeScore = rankScoreMap(judgePop); + Map ratingScore = rankScoreMap(ratingPop); + Map popularScore = rankScoreMap(popularPop); + + Set grandPop = new LinkedHashSet<>(); + grandPop.addAll(judgePop.keySet()); + grandPop.addAll(ratingPop.keySet()); + grandPop.addAll(popularPop.keySet()); + if (grandPop.isEmpty()) { + return 0; + } + + Map grand = new LinkedHashMap<>(); + for (Long gameId : grandPop) { + double weighted = 0.0; + double weightSum = 0.0; + if (judgeScore.containsKey(gameId)) { + weighted += judgeScore.get(gameId) * WEIGHT_JUDGE; + weightSum += WEIGHT_JUDGE; + } + if (ratingScore.containsKey(gameId)) { + weighted += ratingScore.get(gameId) * WEIGHT_USER_RATING; + weightSum += WEIGHT_USER_RATING; + } + if (popularScore.containsKey(gameId)) { + weighted += popularScore.get(gameId) * WEIGHT_POPULAR; + weightSum += WEIGHT_POPULAR; + } + // grandPop 은 최소 1트랙 가용이므로 weightSum > 0 보장 + grand.put(gameId, weighted / weightSum); + } + + List games = new ArrayList<>(grand.keySet()); + Comparator byScore = Comparator.comparingDouble( + (Long g) -> grand.get(g)).reversed(); + Comparator byOrder = byScore.thenComparing(Comparator.naturalOrder()); + Map ranks = RankScores.standardCompetitionRank(games, byOrder, byScore); + + int n = 0; + for (Map.Entry e : ranks.entrySet()) { + jamAwardsMapper.insert(award(jamId, e.getKey(), AwardTrack.GRAND, e.getValue(), + BigDecimal.valueOf(grand.get(e.getKey())))); + n++; + } + return n; + } + + /** 모집단 gameId -> rankScore (N-rank+1)/N. 빈 모집단이면 빈 맵. */ + private Map rankScoreMap(Map population) { + if (population.isEmpty()) { + return Map.of(); + } + List games = new ArrayList<>(population.keySet()); + Comparator byScore = Comparator.comparingDouble( + (Long g) -> population.get(g)).reversed(); + Comparator byOrder = byScore.thenComparing(Comparator.naturalOrder()); + return RankScores.rankScore(games, byOrder, byScore); + } + + /** + * 집계 소스 Map 리스트를 gameId -> 점수 모집단으로 변환. 점수 NULL 행은 제외(A4). + * JUDGE 의 weighted_total NULL(채점 0) 행 필터 위치이기도 하다(매퍼 NULLS LAST 후). + */ + private Map trackPopulation(List> rows, + String idKey, String scoreKey) { + Map population = new LinkedHashMap<>(); + for (Map row : rows) { + Object idObj = row.get(idKey); + Object scoreObj = row.get(scoreKey); + if (idObj == null || scoreObj == null) { + continue; // 점수 NULL → 트랙 제외(부당 0점 회피) + } + long gameId = ((Number) idObj).longValue(); + double score = ((Number) scoreObj).doubleValue(); + population.put(gameId, score); + } + return population; + } + + private JamAwardData award(long jamId, long gameId, AwardTrack track, int rank, BigDecimal scoreValue) { + JamAwardData award = new JamAwardData(); + award.setJamId(jamId); + award.setGameId(gameId); + award.setAwardTrack(track.name()); + award.setRank(rank); + award.setScoreValue(scoreValue); + return award; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/jam/RankScores.java b/src/main/java/com/pandoli365/bibimbap/jam/RankScores.java new file mode 100644 index 0000000..fdbf7d6 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/jam/RankScores.java @@ -0,0 +1,84 @@ +package com.pandoli365.bibimbap.jam; + +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 순위점수 유틸 — jam_awards rank 부여와 GRAND 정규화 점수 산정을 공유(중복 0). + * + *

두 메서드 모두 정렬 대상 리스트 + 점수 내림차순 비교자만 받는다(트랙/잼 컨텍스트는 + * 호출자가 보유 — inflate 차단, design concern 1). 비교자는 점수 DESC + tiebreak(game_id ASC) + * 를 캡슐화하므로 정렬은 결정적이고 재산정 시 안정적이다. + * + *

    + *
  • {@link #standardCompetitionRank} — 동점이면 같은 rank, 다음 순위 건너뜀(1,2,2,4).
  • + *
  • {@link #rankScore} — (N - rank + 1) / N ∈ [1/N, 1]. 1위=1.0, 최하위=1/N. + * 트랙 내 상대 순위만 쓰므로 트랙 간 스케일(numeric 1~5 vs vote count 0~수백) 무관. + * GRAND 가중합 입력.
  • + *
+ * + *

동점 정의: 비교자가 0 을 반환하는 인접 항목은 같은 점수로 간주(같은 rank·같은 rankScore). + * 따라서 비교자에 tiebreak(game_id 등)를 포함하면 동점이 깨질 수 있다 — 호출자는 동점을 + * 같은 rank 로 묶으려면 점수 키만으로 동등성을 판정하도록 별도 동등 비교자를 넘기지 않고, + * 본 유틸은 정렬 후 인접 항목의 비교자 결과가 0 인 경우를 동점으로 처리한다. 호출자는 + * tiebreak 정렬용 비교자(점수 DESC, then game_id ASC)를 그대로 전달하면 된다 — 이때 동점 + * 판정은 점수 부분만으로 0 이 되도록 별도 동점 비교자(scoreEqualityComparator)를 함께 받는다. + */ +public final class RankScores { + + private RankScores() { + } + + /** + * standard competition ranking(1,2,2,4). 동점은 같은 rank. + * + * @param items 산정 대상(트랙 모집단) + * @param byOrder 표시·정렬 순서 비교자(점수 DESC + tiebreak game_id ASC) — 결정적 정렬용 + * @param byScore 동점 판정 비교자(점수만; 0 이면 같은 rank) — tiebreak 미포함 + * @return item -> rank(1-based). 입력 순서 보존(LinkedHashMap, byOrder 정렬 순). + */ + public static Map standardCompetitionRank(List items, + Comparator byOrder, + Comparator byScore) { + List sorted = items.stream().sorted(byOrder).toList(); + Map ranks = new LinkedHashMap<>(); + int rank = 0; + int processed = 0; + T prev = null; + for (T item : sorted) { + processed++; + if (prev == null || byScore.compare(prev, item) != 0) { + rank = processed; // 새 점수 구간 시작 — 건너뛴 순위 반영(competition) + } + ranks.put(item, rank); + prev = item; + } + return ranks; + } + + /** + * 순위점수 (N - rank + 1) / N ∈ [1/N, 1]. 동점은 같은 rank → 같은 rankScore. + * N=0 이면 빈 맵(가용 모집단 없음). + * + * @param items 동일 모집단 + * @param byOrder 정렬 순서 비교자(점수 DESC + tiebreak) + * @param byScore 동점 판정 비교자(점수만) + * @return item -> rankScore. GRAND 가중합 입력. + */ + public static Map rankScore(List items, + Comparator byOrder, + Comparator byScore) { + int n = items.size(); + Map scores = new LinkedHashMap<>(); + if (n == 0) { + return scores; + } + Map ranks = standardCompetitionRank(items, byOrder, byScore); + for (Map.Entry e : ranks.entrySet()) { + scores.put(e.getKey(), (n - e.getValue() + 1) / (double) n); + } + return scores; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamAwardsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamAwardsMapper.java new file mode 100644 index 0000000..5d746d8 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamAwardsMapper.java @@ -0,0 +1,76 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.JamAwardData; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * jam_awards CRUD 매퍼 (W2-3 동결 스키마 소비 — docs/jam-eval-ddl.sql §4). + * + *

일반 테이블 매퍼 → POJO 직접 매핑(snake→camel 비인용 alias). 집계 VIEW 가 아니므로 + * §33 큰따옴표 규칙 비적용(POJO 직접매핑은 비인용 OK). + * + *

재산정 멱등: deleteByJamTrack(트랙 초기화) → insert(재기록)를 단일 @Transactional + * 경계(JamAwardService.recompute)에서 수행. score_value 는 트랙별 raw 점수/GRAND 종합점수. + * + *

SQL 은 전부 `#{}` 바인딩(`${}` 동적치환 0). + */ +@Mapper +public interface JamAwardsMapper { + + @Insert(""" + INSERT INTO jam_awards (jam_id, game_id, award_track, rank, score_value) + VALUES (#{jamId}, #{gameId}, #{awardTrack}, #{rank}, #{scoreValue}) + """) + int insert(JamAwardData award); + + @Delete(""" + DELETE FROM jam_awards + WHERE jam_id = #{jamId} + AND award_track = #{track} + """) + int deleteByJamTrack(@Param("jamId") long jamId, @Param("track") String track); + + @Select(""" + SELECT + id, + jam_id AS jamId, + game_id AS gameId, + award_track AS awardTrack, + rank, + score_value AS scoreValue, + computed_at AS computedAt + FROM jam_awards + WHERE jam_id = #{jamId} + ORDER BY award_track ASC, rank ASC, game_id ASC + """) + List listByJam(long jamId); + + @Select(""" + SELECT + a.id, + a.jam_id AS jamId, + a.game_id AS gameId, + a.award_track AS awardTrack, + a.rank, + a.score_value AS scoreValue, + a.computed_at AS computedAt, + g.name AS gameName, + g.thumbnail_url AS thumbnailUrl, + COALESCE(t.name, eu.display_name) AS entrantName + FROM jam_awards a + JOIN games g ON g.id = a.game_id + LEFT JOIN jam_entries e + ON e.jam_id = a.jam_id AND e.game_id = a.game_id AND e.is_delete IS NOT TRUE + LEFT JOIN users eu ON eu.id = e.entrant_user_id + LEFT JOIN jam_teams t ON t.id = e.jam_team_id + WHERE a.jam_id = #{jamId} + ORDER BY a.award_track ASC, a.rank ASC, a.game_id ASC + """) + List listByJamWithGame(long jamId); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamReviewRatingMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamReviewRatingMapper.java new file mode 100644 index 0000000..91e3b4a --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamReviewRatingMapper.java @@ -0,0 +1,39 @@ +package com.pandoli365.bibimbap.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * USER_RATING 트랙 소비 매퍼 — 잼 출품작(jam_entries) ⨝ game_review_stats(집계 VIEW) + * 의 평균 별점/리뷰수를 단방향 SELECT 만 한다(W2-3 G4/F5 단방향, game_reviews/axes write 0). + * + *

game_review_stats 는 집계 VIEW → Map resultType camelCase alias 는 §33 케이스폴딩 + * 회피 위해 큰따옴표 필수(AS "avgRating"/"reviewCount" — GameReviewStatsMapper.java:13-15 선례). + * gameId 는 jam_entries.game_id(일반 컬럼)이나 동일 SELECT 의 Map resultType 정합을 위해 + * 일관되게 큰따옴표 alias 사용. + * + *

F5 임계: review_count >= 3 미달 출품작 제외(부당 0점 회피). 리뷰 0개(VIEW 행 없음)는 + * LEFT JOIN 으로 NULL → review_count NULL → 임계 미달 제외. 6축 컬럼 미참조(단방향 G4). + * + *

정렬: avg_rating DESC NULLS LAST, game_id ASC(결정적 tiebreak). SQL 전부 `#{}`(`${}` 0). + */ +@Mapper +public interface JamReviewRatingMapper { + + @Select(""" + SELECT + e.game_id AS "gameId", + st.avg_rating AS "avgRating", + st.review_count AS "reviewCount" + FROM jam_entries e + LEFT JOIN game_review_stats st ON st.game_id = e.game_id + WHERE e.jam_id = #{jamId} + AND e.is_delete IS NOT TRUE + AND st.review_count >= 3 + ORDER BY st.avg_rating DESC NULLS LAST, e.game_id ASC + """) + List> listAvgByJam(long jamId); +} diff --git a/src/main/webapp/WEB-INF/views/jam-detail.jsp b/src/main/webapp/WEB-INF/views/jam-detail.jsp index 8b07238..71523c7 100644 --- a/src/main/webapp/WEB-INF/views/jam-detail.jsp +++ b/src/main/webapp/WEB-INF/views/jam-detail.jsp @@ -3,12 +3,14 @@ <%@ page import="com.pandoli365.bibimbap.data.JamData" %> <%@ page import="com.pandoli365.bibimbap.data.JamEntryData" %> <%@ page import="com.pandoli365.bibimbap.data.JamTeamData" %> +<%@ page import="com.pandoli365.bibimbap.data.JamAwardData" %> <%@ page import="org.springframework.web.util.HtmlUtils" %> <% String ctx = request.getContextPath(); JamData jam = (JamData) request.getAttribute("jam"); List entries = (List) request.getAttribute("entries"); List teams = (List) request.getAttribute("teams"); + List awardsSummary = (List) request.getAttribute("awardsSummary"); Object rawCsrf = request.getAttribute("csrfToken"); String csrfToken = rawCsrf == null ? "" : String.valueOf(rawCsrf); String csrfTokenHtml = HtmlUtils.htmlEscape(csrfToken); @@ -323,6 +325,44 @@ } %> +<% + if (awardsSummary != null && !awardsSummary.isEmpty()) { +%> +

+

수상 결과

+
    +<% + for (JamAwardData award : awardsSummary) { + if (award == null) { + continue; + } + String awardTrack = award.getAwardTrack(); + Integer awardRank = award.getRank(); + boolean isGrand = "GRAND".equals(awardTrack); + // GRAND 트랙은 상위 순위 전부, 그 외 트랙은 1위(대상)만 요약 배지로 노출 + if (!isGrand && (awardRank == null || awardRank.intValue() != 1)) { + continue; + } + String awardGameName = award.getGameName() == null || award.getGameName().isBlank() ? "이름 없는 게임" : award.getGameName(); + String rankLabel = awardRank == null ? "" : (awardRank.intValue() + "위"); + String trackLabel = isGrand ? "종합" : (awardTrack == null || awardTrack.isBlank() ? "" : awardTrack); +%> +
  • + <%= trackLabel.isBlank() ? "" : HtmlUtils.htmlEscape(trackLabel) + (rankLabel.isBlank() ? "" : " " + rankLabel) %> + <%= HtmlUtils.htmlEscape(awardGameName) %> +
  • +<% + } +%> +
+ +
+<% + } +%> +

출품작

<% diff --git a/src/main/webapp/WEB-INF/views/jam-results.jsp b/src/main/webapp/WEB-INF/views/jam-results.jsp new file mode 100644 index 0000000..bc068fb --- /dev/null +++ b/src/main/webapp/WEB-INF/views/jam-results.jsp @@ -0,0 +1,301 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %> +<%@ page import="java.util.List" %> +<%@ page import="java.util.Map" %> +<%@ page import="com.pandoli365.bibimbap.data.JamData" %> +<%@ page import="com.pandoli365.bibimbap.data.JamAwardData" %> +<%@ page import="org.springframework.web.util.HtmlUtils" %> +<% + String ctx = request.getContextPath(); + JamData jam = (JamData) request.getAttribute("jam"); + Map> byTrack = + (Map>) request.getAttribute("byTrack"); + List grand = (List) request.getAttribute("grand"); + boolean noResults = (byTrack == null || byTrack.isEmpty()); +%> + + + + + + + <%= jam == null ? "게임잼 결과" : HtmlUtils.htmlEscape(jam.getTitle() == null ? "게임잼 결과" : jam.getTitle()) %> 결과 | bibimbap + + + + +
+ ← 게임잼 목록 +
+

<%= jam == null ? "게임잼 결과" : HtmlUtils.htmlEscape(jam.getTitle() == null ? "게임잼" : jam.getTitle()) %> 결과

+
+<% + if (noResults) { +%> +
+

결과 준비 중입니다.

+
+<% + } else { +%> +
+

종합 대상

+<% + if (grand == null || grand.isEmpty()) { +%> +

해당 트랙 결과 없음

+<% + } else { +%> + + + + + +<% + for (JamAwardData award : grand) { + if (award == null) { + continue; + } + Integer rank = award.getRank(); + Long gameId = award.getGameId(); + String gameName = award.getGameName() == null || award.getGameName().isBlank() ? "이름 없는 게임" : award.getGameName(); + String thumbnailUrl = award.getThumbnailUrl(); + String entrantName = award.getEntrantName(); + java.math.BigDecimal scoreValue = award.getScoreValue(); +%> + + + + + + +<% + } +%> + +
순위게임출품자점수
<%= rank == null ? "-" : rank.intValue() %> +<% + if (thumbnailUrl != null && !thumbnailUrl.isBlank()) { +%> + +<% + } + if (gameId != null) { +%> + <%= HtmlUtils.htmlEscape(gameName) %> +<% + } else { +%> + <%= HtmlUtils.htmlEscape(gameName) %> +<% + } +%> + <%= entrantName == null || entrantName.isBlank() ? "" : HtmlUtils.htmlEscape(entrantName) %><%= scoreValue == null ? "" : HtmlUtils.htmlEscape(String.valueOf(scoreValue)) %>
+<% + } +%> +
+<% + String[] trackKeys = { "JUDGE", "USER_RATING", "POPULAR" }; + String[] trackLabels = { "심사상", "유저 평점상", "인기상" }; + for (int t = 0; t < trackKeys.length; t++) { + String trackKey = trackKeys[t]; + String trackLabel = trackLabels[t]; + List rows = byTrack.get(trackKey); +%> +
+

<%= trackLabel %>

+<% + if (rows == null || rows.isEmpty()) { +%> +

해당 트랙 결과 없음

+<% + } else { +%> + + + + + +<% + for (JamAwardData award : rows) { + if (award == null) { + continue; + } + Integer rank = award.getRank(); + Long gameId = award.getGameId(); + String gameName = award.getGameName() == null || award.getGameName().isBlank() ? "이름 없는 게임" : award.getGameName(); + String thumbnailUrl = award.getThumbnailUrl(); + String entrantName = award.getEntrantName(); + java.math.BigDecimal scoreValue = award.getScoreValue(); +%> + + + + + + +<% + } +%> + +
순위게임출품자점수
<%= rank == null ? "-" : rank.intValue() %> +<% + if (thumbnailUrl != null && !thumbnailUrl.isBlank()) { +%> + +<% + } + if (gameId != null) { +%> + <%= HtmlUtils.htmlEscape(gameName) %> +<% + } else { +%> + <%= HtmlUtils.htmlEscape(gameName) %> +<% + } +%> + <%= entrantName == null || entrantName.isBlank() ? "" : HtmlUtils.htmlEscape(entrantName) %><%= scoreValue == null ? "" : HtmlUtils.htmlEscape(String.valueOf(scoreValue)) %>
+<% + } +%> +
+<% + } +%> +<% + } +%> +
+ + + diff --git a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java index ea8d621..22e5fa9 100644 --- a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java +++ b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java @@ -5,9 +5,11 @@ import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper; import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper; import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GamesMapper; +import com.pandoli365.bibimbap.mapper.JamAwardsMapper; import com.pandoli365.bibimbap.mapper.JamCriteriaMapper; import com.pandoli365.bibimbap.mapper.JamEntriesMapper; import com.pandoli365.bibimbap.mapper.JamJudgesMapper; +import com.pandoli365.bibimbap.mapper.JamReviewRatingMapper; import com.pandoli365.bibimbap.mapper.JamScoreStatsMapper; import com.pandoli365.bibimbap.mapper.JamScoresMapper; import com.pandoli365.bibimbap.mapper.JamStatusLogMapper; @@ -98,6 +100,12 @@ class BibimbapApplicationTests { @MockBean private JamVotesMapper jamVotesMapper; + @MockBean + private JamAwardsMapper jamAwardsMapper; + + @MockBean + private JamReviewRatingMapper jamReviewRatingMapper; + @MockBean private PermissionGate permissionGate; diff --git a/src/test/java/com/pandoli365/bibimbap/controller/JamAwardAdminControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/JamAwardAdminControllerTest.java new file mode 100644 index 0000000..d6f1732 --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/JamAwardAdminControllerTest.java @@ -0,0 +1,239 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.JamData; +import com.pandoli365.bibimbap.jam.JamAwardService; +import com.pandoli365.bibimbap.mapper.JamsMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; +import com.pandoli365.bibimbap.security.PermissionGate; +import com.pandoli365.bibimbap.security.PermissionKeys; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; + +import java.time.OffsetDateTime; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * F6 시상 산정 관리자 컨트롤러 단위 테스트 (plain Mockito, MockMvc 미사용). + * + *

computeAwards 의 게이트 순서(인증 401 → 인가 403 → CSRF 403 → 잼존재 404 → + * F6 산정 가능 게이트 422 → 산정 200)를 전수 검증한다. F6 게이트는 + * status="CLOSED" 또는 now>evalEndAt 인 경우에만 통과한다. + * + *

VP-5 매핑은 메서드 주석 참조. + */ +@ExtendWith(MockitoExtension.class) +class JamAwardAdminControllerTest { + + private static final String GAME_JAM_MANAGE = PermissionKeys.GAME_JAM_MANAGE.name(); + private static final long ACTOR_ID = 99L; + private static final long JAM_ID = 7L; + + @Mock + private JamsMapper jamsMapper; + + @Mock + private PermissionGate gate; + + @Mock + private JamAwardService jamAwardService; + + /** VP-5: 미인증 → 401, 산정·잼조회 미수행. */ + @Test + void computeReturns401WhenUnauthenticated() { + JamAwardAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(false); + + ResponseEntity> response = + controller.computeAwards(JAM_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody()).containsEntry("status", HttpStatus.UNAUTHORIZED.value()); + verify(jamAwardService, never()).recompute(anyLong()); + verify(jamsMapper, never()).getById(anyLong()); + } + + /** VP-5: 미인가 → 403, 산정 미수행. */ + @Test + void computeReturns403WhenLacksPermission() { + JamAwardAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(false); + + ResponseEntity> response = + controller.computeAwards(JAM_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).containsEntry("status", HttpStatus.FORBIDDEN.value()); + verify(jamAwardService, never()).recompute(anyLong()); + } + + /** VP-5: 게이트 통과 후 CSRF 누락 → 403, 잼조회·산정 미수행. */ + @Test + void computeRejectsMissingCsrf() { + JamAwardAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = noCsrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true); + + ResponseEntity> response = + controller.computeAwards(JAM_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).containsEntry("status", 403); + verify(jamsMapper, never()).getById(anyLong()); + verify(jamAwardService, never()).recompute(anyLong()); + } + + /** VP-5: 잼 없음 → 404, 산정 미수행. */ + @Test + void computeReturns404WhenJamMissing() { + JamAwardAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true); + when(jamsMapper.getById(JAM_ID)).thenReturn(null); + + ResponseEntity> response = + controller.computeAwards(JAM_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + verify(jamAwardService, never()).recompute(anyLong()); + } + + /** VP-5: CLOSED 아니고 평가기간 진행 중 → 422, 산정 미수행. */ + @Test + void computeReturns422WhenNotClosedAndEvalOngoing() { + JamAwardAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true); + when(jamsMapper.getById(JAM_ID)).thenReturn(evalOngoingJam()); + + ResponseEntity> response = + controller.computeAwards(JAM_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + assertThat(response.getBody()).containsEntry("message", "시상 산정 가능 상태가 아닙니다."); + verify(jamAwardService, never()).recompute(anyLong()); + } + + /** VP-5: CLOSED 잼 → F6 통과 → 200, jamId·awardCounts 노출, recompute 호출. */ + @Test + void computeSucceedsWhenClosed() { + JamAwardAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true); + when(jamsMapper.getById(JAM_ID)).thenReturn(closedJam()); + when(jamAwardService.recompute(JAM_ID)).thenReturn(Map.of( + "JUDGE", 2, + "USER_RATING", 2, + "POPULAR", 2, + "GRAND", 2 + )); + + ResponseEntity> response = + controller.computeAwards(JAM_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("jamId", JAM_ID); + assertThat(response.getBody().get("awardCounts")).isNotNull(); + verify(jamAwardService).recompute(JAM_ID); + } + + /** VP-5: EVAL 상태지만 평가 종료(now>evalEndAt) → F6 통과 → 200, recompute 호출. */ + @Test + void computeSucceedsWhenEvalEnded() { + JamAwardAdminController controller = controller(); + MockHttpSession session = managerSession(ACTOR_ID); + MockHttpServletRequest request = csrfPost(session); + when(gate.isAuthenticated(session)).thenReturn(true); + when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true); + when(jamsMapper.getById(JAM_ID)).thenReturn(evalEndedJam()); + when(jamAwardService.recompute(JAM_ID)).thenReturn(Map.of( + "JUDGE", 2, + "USER_RATING", 2, + "POPULAR", 2, + "GRAND", 2 + )); + + ResponseEntity> response = + controller.computeAwards(JAM_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("jamId", JAM_ID); + assertThat(response.getBody().get("awardCounts")).isNotNull(); + verify(jamAwardService).recompute(JAM_ID); + } + + // ==== helpers ==== + + private JamAwardAdminController controller() { + return new JamAwardAdminController(jamsMapper, gate, jamAwardService); + } + + private JamData closedJam() { + JamData jam = new JamData(); + jam.setId(JAM_ID); + jam.setStatus("CLOSED"); + return jam; + } + + /** 평가 진행 중 — status EVAL + evalEndAt 미래(F6 게이트 422 검증용). */ + private JamData evalOngoingJam() { + JamData jam = new JamData(); + jam.setId(JAM_ID); + jam.setStatus("EVAL"); + jam.setEvalEndAt(OffsetDateTime.now().plusHours(1)); + return jam; + } + + /** 평가 종료 — status EVAL 이지만 evalEndAt 과거(F6 게이트 통과 검증용). */ + private JamData evalEndedJam() { + JamData jam = new JamData(); + jam.setId(JAM_ID); + jam.setStatus("EVAL"); + jam.setEvalEndAt(OffsetDateTime.now().minusHours(1)); + return jam; + } + + private MockHttpSession managerSession(long actorId) { + MockHttpSession session = new MockHttpSession(); + session.setAttribute("userId", actorId); + CsrfTokens.getOrCreate(session); + return session; + } + + private MockHttpServletRequest csrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + request.addHeader(CsrfTokens.HEADER_NAME, (String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE)); + return request; + } + + private MockHttpServletRequest noCsrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + return request; + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/controller/JamAwardControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/JamAwardControllerTest.java new file mode 100644 index 0000000..594cffc --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/JamAwardControllerTest.java @@ -0,0 +1,156 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.JamAwardData; +import com.pandoli365.bibimbap.data.JamData; +import com.pandoli365.bibimbap.mapper.JamAwardsMapper; +import com.pandoli365.bibimbap.mapper.JamsMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.Model; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * W2-3 시상 결과 페이지 컨트롤러 단위 테스트 (plain Mockito, MockMvc 미사용). + * + *

results 노출 게이트(잼 없음/은닉 → /jams 리다이렉트)와 awardTrack 별 그룹핑 + * (byTrack LinkedHashMap + grand 파생)을 검증한다. results 는 세션 인자가 없는 + * 공개 GET 이므로 익명 접근 시에도 잼 가시 시 jam-results 뷰를 반환한다. + */ +@ExtendWith(MockitoExtension.class) +class JamAwardControllerTest { + + private static final long JAM_ID = 42L; + private static final String SLUG = "my-jam"; + + @Mock + private JamsMapper jamsMapper; + + @Mock + private JamAwardsMapper jamAwardsMapper; + + /** 잼 없음 → /jams 리다이렉트, 시상 조회 전 차단. */ + @Test + void resultsRedirectsWhenJamMissing() { + JamAwardController controller = controller(); + Model model = new ExtendedModelMap(); + when(jamsMapper.getBySlug(SLUG)).thenReturn(null); + + String view = controller.results(SLUG, model); + + assertThat(view).isEqualTo("redirect:/jams"); + verify(jamAwardsMapper, never()).listByJamWithGame(anyLong()); + } + + /** 은닉 잼(isVisible=false) → /jams 리다이렉트, 시상 조회 전 차단. */ + @Test + void resultsRedirectsWhenJamHidden() { + JamAwardController controller = controller(); + Model model = new ExtendedModelMap(); + when(jamsMapper.getBySlug(SLUG)).thenReturn(hiddenJam()); + + String view = controller.results(SLUG, model); + + assertThat(view).isEqualTo("redirect:/jams"); + verify(jamAwardsMapper, never()).listByJamWithGame(anyLong()); + } + + /** 가시 잼 + 미산정(빈 시상) → jam-results, byTrack 빈 Map / grand 빈 List. */ + @Test + void resultsRendersEmptyWhenNoAwards() { + JamAwardController controller = controller(); + Model model = new ExtendedModelMap(); + when(jamsMapper.getBySlug(SLUG)).thenReturn(visibleJam()); + when(jamAwardsMapper.listByJamWithGame(JAM_ID)).thenReturn(List.of()); + + String view = controller.results(SLUG, model); + + assertThat(view).isEqualTo("jam-results"); + assertThat(byTrack(model)).isEmpty(); + assertThat((List) model.getAttribute("grand")).isEmpty(); + } + + /** 가시 잼 + 다트랙 시상 → awardTrack 별 그룹핑, grand 는 GRAND 트랙 파생. */ + @Test + void resultsGroupsByTrack() { + JamAwardController controller = controller(); + Model model = new ExtendedModelMap(); + when(jamsMapper.getBySlug(SLUG)).thenReturn(visibleJam()); + when(jamAwardsMapper.listByJamWithGame(JAM_ID)).thenReturn(List.of( + award("JUDGE", 1, 7L), + award("JUDGE", 2, 8L), + award("GRAND", 1, 7L), + award("POPULAR", 1, 9L) + )); + + String view = controller.results(SLUG, model); + + assertThat(view).isEqualTo("jam-results"); + Map> byTrack = byTrack(model); + assertThat(byTrack).containsOnlyKeys("JUDGE", "GRAND", "POPULAR"); + assertThat(byTrack.get("JUDGE")).hasSize(2); + assertThat(byTrack.get("GRAND")).hasSize(1); + assertThat(byTrack.get("POPULAR")).hasSize(1); + assertThat((List) model.getAttribute("grand")).hasSize(1); + } + + /** results 는 세션 인자 없음 — 미로그인도 가시 잼 시 jam-results 뷰 반환. */ + @Test + void resultsAccessibleWhenAnonymous() { + JamAwardController controller = controller(); + Model model = new ExtendedModelMap(); + when(jamsMapper.getBySlug(SLUG)).thenReturn(visibleJam()); + when(jamAwardsMapper.listByJamWithGame(JAM_ID)).thenReturn(List.of()); + + String view = controller.results(SLUG, model); + + assertThat(view).isEqualTo("jam-results"); + assertThat(model.getAttribute("jam")).isNotNull(); + } + + // ==== helpers ==== + + private JamAwardController controller() { + return new JamAwardController(jamsMapper, jamAwardsMapper); + } + + @SuppressWarnings("unchecked") + private Map> byTrack(Model model) { + return (Map>) model.getAttribute("byTrack"); + } + + private JamData visibleJam() { + JamData jam = new JamData(); + jam.setId(JAM_ID); + jam.setSlug(SLUG); + jam.setIsVisible(true); + return jam; + } + + private JamData hiddenJam() { + JamData jam = new JamData(); + jam.setId(JAM_ID); + jam.setSlug(SLUG); + jam.setIsVisible(false); + return jam; + } + + private JamAwardData award(String track, int rank, long gameId) { + JamAwardData a = new JamAwardData(); + a.setAwardTrack(track); + a.setRank(rank); + a.setGameId(gameId); + a.setGameName("게임" + gameId); + return a; + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/controller/JamControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/JamControllerTest.java index 16e420a..d983d1a 100644 --- a/src/test/java/com/pandoli365/bibimbap/controller/JamControllerTest.java +++ b/src/test/java/com/pandoli365/bibimbap/controller/JamControllerTest.java @@ -5,6 +5,7 @@ import com.pandoli365.bibimbap.data.JamData; import com.pandoli365.bibimbap.data.JamEntryData; import com.pandoli365.bibimbap.data.JamTeamData; import com.pandoli365.bibimbap.mapper.GamesMapper; +import com.pandoli365.bibimbap.mapper.JamAwardsMapper; import com.pandoli365.bibimbap.mapper.JamEntriesMapper; import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper; import com.pandoli365.bibimbap.mapper.JamTeamsMapper; @@ -60,6 +61,9 @@ class JamControllerTest { @Mock private GamesMapper gamesMapper; + @Mock + private JamAwardsMapper jamAwardsMapper; + // ---- list (VP-4 keyset) ---- @Test @@ -294,7 +298,7 @@ class JamControllerTest { // ---- helpers ---- private JamController controller() { - return new JamController(jamsMapper, jamEntriesMapper, jamTeamsMapper, jamTeamMembersMapper, gamesMapper); + return new JamController(jamsMapper, jamEntriesMapper, jamTeamsMapper, jamTeamMembersMapper, gamesMapper, jamAwardsMapper); } private MockHttpSession userSession(long userId) { diff --git a/src/test/java/com/pandoli365/bibimbap/jam/JamAwardServiceTest.java b/src/test/java/com/pandoli365/bibimbap/jam/JamAwardServiceTest.java new file mode 100644 index 0000000..2c92b03 --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/jam/JamAwardServiceTest.java @@ -0,0 +1,297 @@ +package com.pandoli365.bibimbap.jam; + +import com.pandoli365.bibimbap.data.JamAwardData; +import com.pandoli365.bibimbap.mapper.JamAwardsMapper; +import com.pandoli365.bibimbap.mapper.JamReviewRatingMapper; +import com.pandoli365.bibimbap.mapper.JamScoreStatsMapper; +import com.pandoli365.bibimbap.mapper.JamVotesMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * W2-? 시상 산정 코어 단위 테스트 (plain Mockito, MockitoExtension — SpringBootTest 미사용). + * + *

4개 소스 매퍼를 모킹하고 {@link JamAwardService#recompute(long)} 가 jam_awards 에 + * INSERT 하는 {@link JamAwardData} 를 ArgumentCaptor 로 포착해 트랙별 rank/gameId/scoreValue + * 불변식을 검증한다. 산정 코어(AC-1~5), NULL/미달 제외(AC-6), 동점 competition rank(AC-5), + * 멱등 선행 초기화(AC-8) 를 커버한다. + * + *

VP-1~VP-4 매핑은 메서드 주석 참조. + */ +@ExtendWith(MockitoExtension.class) +class JamAwardServiceTest { + + private static final long JAM_ID = 42L; + + @Mock + private JamAwardsMapper jamAwardsMapper; + + @Mock + private JamScoreStatsMapper jamScoreStatsMapper; + + @Mock + private JamReviewRatingMapper jamReviewRatingMapper; + + @Mock + private JamVotesMapper jamVotesMapper; + + // ==== VP-1: 산정 코어(AC-1~5) ==== + + /** + * VP-1/AC-1·5: 세 트랙을 raw 점수 DESC competition rank 로 산정. JUDGE 모집단 + * weightedTotal 4.5/3.0/4.5 (game 10/20/30) → game10·30 동점 rank1, game20 rank3. + * USER_RATING·POPULAR 도 DESC rank 로 모킹·검증. + */ + @Test + void recompute_ranksThreeTracksByScoreDesc() { + when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", 10L, "weightedTotal", 4.5), + Map.of("gameId", 20L, "weightedTotal", 3.0), + Map.of("gameId", 30L, "weightedTotal", 4.5) + )); + when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", 10L, "avgRating", 4.2), + Map.of("gameId", 20L, "avgRating", 3.1) + )); + when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", 10L, "voteCount", 5L), + Map.of("gameId", 20L, "voteCount", 9L) + )); + + Map counts = service().recompute(JAM_ID); + + assertThat(counts).containsEntry("JUDGE", 3); + assertThat(counts).containsEntry("USER_RATING", 2); + assertThat(counts).containsEntry("POPULAR", 2); + + List judge = insertsForTrack(captureInserts(), "JUDGE"); + // 동점(4.5) game10·30 → rank1, game20(3.0) → rank3 (competition: 2 건너뜀). + assertThat(rankOf(judge, 10L)).isEqualTo(1); + assertThat(rankOf(judge, 30L)).isEqualTo(1); + assertThat(rankOf(judge, 20L)).isEqualTo(3); + + // USER_RATING DESC: game10(4.2) rank1, game20(3.1) rank2. + List rating = insertsForTrack(captureInserts(), "USER_RATING"); + assertThat(rankOf(rating, 10L)).isEqualTo(1); + assertThat(rankOf(rating, 20L)).isEqualTo(2); + + // POPULAR DESC: game20(9) rank1, game10(5) rank2. + List popular = insertsForTrack(captureInserts(), "POPULAR"); + assertThat(rankOf(popular, 20L)).isEqualTo(1); + assertThat(rankOf(popular, 10L)).isEqualTo(2); + } + + /** + * VP-1/AC-2·3: 두 게임이 3트랙 모두 가용 → GRAND 는 가용트랙 rankScore 가중평균. + * GRAND insert 의 rank 와 scoreValue([0,1] 범위) 검증. + */ + @Test + void recompute_grandIsWeightedRankScoreOfAvailableTracks() { + when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", 10L, "weightedTotal", 4.5), + Map.of("gameId", 20L, "weightedTotal", 3.0) + )); + when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", 10L, "avgRating", 4.2), + Map.of("gameId", 20L, "avgRating", 3.1) + )); + when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", 10L, "voteCount", 9L), + Map.of("gameId", 20L, "voteCount", 5L) + )); + + Map counts = service().recompute(JAM_ID); + assertThat(counts).containsEntry("GRAND", 2); + + List grand = insertsForTrack(captureInserts(), "GRAND"); + // game10 이 3트랙 모두 1위 → rankScore 1.0 가중평균 = 1.0 → GRAND rank1. + assertThat(rankOf(grand, 10L)).isEqualTo(1); + assertThat(rankOf(grand, 20L)).isEqualTo(2); + for (JamAwardData a : grand) { + assertThat(a.getScoreValue()).isNotNull(); + assertThat(a.getScoreValue().doubleValue()).isBetween(0.0, 1.0); + } + // 2게임 모집단에서 1위 rankScore=(2-1+1)/2=1.0. + assertThat(scoreOf(grand, 10L)).isEqualByComparingTo(BigDecimal.valueOf(1.0)); + } + + /** + * VP-1/AC-3: gameA 는 JUDGE 만, gameB 는 3트랙 가용. GRAND 모집단은 최소 1트랙 + * 가용 union → gameA 도 포함(분모=JUDGE weight 만). GRAND insert 에 gameA 존재 검증. + */ + @Test + void recompute_grandIncludesSingleTrackGame() { + long gameA = 10L; + long gameB = 20L; + when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", gameA, "weightedTotal", 4.5), + Map.of("gameId", gameB, "weightedTotal", 3.0) + )); + when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", gameB, "avgRating", 4.0) + )); + when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", gameB, "voteCount", 7L) + )); + + service().recompute(JAM_ID); + + List grand = insertsForTrack(captureInserts(), "GRAND"); + assertThat(grand).extracting(JamAwardData::getGameId) + .contains(gameA, gameB); + } + + // ==== VP-2: NULL/미달 제외(AC-6) ==== + + /** + * VP-2/AC-6: JUDGE 행 중 weightedTotal null 게임은 JUDGE 모집단에서 제외 → + * 해당 gameId 로 JUDGE insert 0. (USER_RATING 임계는 매퍼가 처리 — 서비스 테스트는 + * 매퍼가 그 게임을 빼고 반환했다고 모킹 → USER_RATING insert 안 됨.) + */ + @Test + void recompute_excludesNullWeightedTotalFromJudge() { + long scored = 10L; + long unscored = 20L; // weightedTotal NULL → JUDGE 제외 + Map nullRow = new HashMap<>(); + nullRow.put("gameId", unscored); + nullRow.put("weightedTotal", null); + when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", scored, "weightedTotal", 4.5), + nullRow + )); + // USER_RATING 매퍼가 임계 미달 게임(unscored)을 이미 필터해 scored 만 반환. + when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", scored, "avgRating", 4.0) + )); + when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of()); + + Map counts = service().recompute(JAM_ID); + assertThat(counts).containsEntry("JUDGE", 1); + + List all = captureInserts(); + List judge = insertsForTrack(all, "JUDGE"); + assertThat(judge).extracting(JamAwardData::getGameId).containsExactly(scored); + assertThat(judge).extracting(JamAwardData::getGameId).doesNotContain(unscored); + + // USER_RATING 에 임계 미달 게임(unscored) insert 없음. + List rating = insertsForTrack(all, "USER_RATING"); + assertThat(rating).extracting(JamAwardData::getGameId).doesNotContain(unscored); + } + + /** + * VP-2: 모든 소스 빈 리스트 → 4트랙 insert 0, counts 전부 0, deleteByJamTrack 4회 + * 호출(멱등 초기화). insert 미발생. + */ + @Test + void recompute_emptySourcesProduceNoAwards() { + when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of()); + when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of()); + when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of()); + + Map counts = service().recompute(JAM_ID); + + assertThat(counts).containsEntry("JUDGE", 0); + assertThat(counts).containsEntry("USER_RATING", 0); + assertThat(counts).containsEntry("POPULAR", 0); + assertThat(counts).containsEntry("GRAND", 0); + + verify(jamAwardsMapper, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any()); + verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "JUDGE"); + verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "USER_RATING"); + verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "POPULAR"); + verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "GRAND"); + } + + // ==== VP-3: 동점 competition rank(AC-5) ==== + + /** + * VP-3/AC-5: voteCount 동일 2게임 → POPULAR 같은 rank, 다음 순위 건너뜀(competition). + * game_id ASC tiebreak 로 결정적. game10·20 동점(rank1), game30(낮음) rank3. + */ + @Test + void recompute_tiesGetSameRank() { + when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of()); + when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of()); + when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", 10L, "voteCount", 7L), + Map.of("gameId", 20L, "voteCount", 7L), + Map.of("gameId", 30L, "voteCount", 2L) + )); + + service().recompute(JAM_ID); + + List popular = insertsForTrack(captureInserts(), "POPULAR"); + assertThat(rankOf(popular, 10L)).isEqualTo(1); + assertThat(rankOf(popular, 20L)).isEqualTo(1); + // competition: 2위 건너뛰고 3위. + assertThat(rankOf(popular, 30L)).isEqualTo(3); + } + + // ==== VP-4: 멱등 선행 초기화(AC-8) ==== + + /** + * VP-4/AC-8: recompute 1회에서 deleteByJamTrack 가 4트랙 전부 호출(멱등 선행 초기화). + * delete 선행으로 재산정 누적 회피 — delete 호출 검증으로 대리. + */ + @Test + void recompute_deletesEachTrackBeforeInsert() { + when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", 10L, "weightedTotal", 4.0) + )); + when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of()); + when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of()); + + service().recompute(JAM_ID); + + verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "JUDGE"); + verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "USER_RATING"); + verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "POPULAR"); + verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "GRAND"); + } + + // ==== helpers ==== + + private JamAwardService service() { + return new JamAwardService(jamAwardsMapper, jamScoreStatsMapper, + jamReviewRatingMapper, jamVotesMapper); + } + + /** jam_awards.insert 로 전달된 모든 JamAwardData 포착. */ + private List captureInserts() { + ArgumentCaptor captor = ArgumentCaptor.forClass(JamAwardData.class); + verify(jamAwardsMapper, org.mockito.Mockito.atLeastOnce()).insert(captor.capture()); + return captor.getAllValues(); + } + + private List insertsForTrack(List all, String track) { + return all.stream().filter(a -> track.equals(a.getAwardTrack())).toList(); + } + + private int rankOf(List awards, long gameId) { + return awards.stream() + .filter(a -> a.getGameId() != null && a.getGameId() == gameId) + .map(JamAwardData::getRank) + .findFirst() + .orElseThrow(() -> new AssertionError("no award for gameId=" + gameId)); + } + + private BigDecimal scoreOf(List awards, long gameId) { + return awards.stream() + .filter(a -> a.getGameId() != null && a.getGameId() == gameId) + .map(JamAwardData::getScoreValue) + .findFirst() + .orElseThrow(() -> new AssertionError("no award for gameId=" + gameId)); + } +}