feat(jam): W2-6 시상 집계 — 3트랙 개별수상 + 가중 GRAND + CLOSED 확정 멱등

- 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) <noreply@anthropic.com>
This commit is contained in:
이정수 2026-06-24 12:40:51 +09:00
parent 5ed06d9942
commit a74bf74d13
16 changed files with 1733 additions and 2 deletions

View File

@ -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<Map<String, Object>> computeAwards(
@PathVariable("jamId") long jamId,
HttpServletRequest request,
HttpSession session) {
ResponseEntity<Map<String, Object>> 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<String, Integer> awardCounts = jamAwardService.recompute(jam.getId());
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}

View File

@ -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<JamAwardData> awards = jamAwardsMapper.listByJamWithGame(jam.getId());
Map<String, List<JamAwardData>> 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";
}
}

View File

@ -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";
}

View File

@ -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).
*
* <p>핵심 컬럼(jamId/gameId/awardTrack/rank/scoreValue/computedAt) 더해
* 결과 페이지 표시용 JOIN 필드(gameName/thumbnailUrl/entrantName) 보유한다.
* 표시 필드는 listByJamWithGame(JOIN games + jam_entries) 조회에서만 채워지고,
* INSERT 시에는 핵심 컬럼만 사용한다(표시 필드 write 0).
*
* <p>POJO 직접 매핑이므로 매퍼 alias snakecamel 비인용(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;
}
}

View File

@ -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 동결 스키마 동시 변경 필요.
*
* <ul>
* <li>JUDGE 심사 가중 종합점수(jam_score_stats.weighted_total)</li>
* <li>USER_RATING 유저 평균 별점(game_review_stats.avg_rating, review_count>=3 임계)</li>
* <li>POPULAR 인기투표 득표수(jam_votes count)</li>
* <li>GRAND 3트랙 순위점수 정규화 가중합 종합</li>
* </ul>
*/
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;
}
}
}

View File

@ -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).
*
* <p>멱등 재산정: {@link #recompute} 4트랙 deleteByJamTrack 재INSERT 단일
* @Transactional 경계에서 수행 돌려도 결과 동일, 부분 실패 트랙 비는 상태 회피.
*
* <p>NULL/미달 트랙 제외(A4, 부당 0점 회피):
* <ul>
* <li>JUDGE weighted_total NULL(채점 0) 제외(매퍼 NULLS LAST 코드 필터)</li>
* <li>USER_RATING review_count>=3 임계(매퍼 WHERE 에서 제외)</li>
* <li>POPULAR 득표>0 출품작만(매퍼 GROUP BY 결과; 0표는 없음)</li>
* <li>GRAND 최소 1트랙 가용 출품작. 가용 트랙만 분자·분모에 포함</li>
* </ul>
*/
@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<String, Integer> recompute(long jamId) {
for (AwardTrack track : AwardTrack.values()) {
jamAwardsMapper.deleteByJamTrack(jamId, track.name());
}
// 트랙별 모집단(gameId -> raw 점수). NULL/미달 제외 후의 수상권 모집단.
Map<Long, Double> judgePop = trackPopulation(
jamScoreStatsMapper.listStatsByJam(jamId), "gameId", "weightedTotal");
Map<Long, Double> ratingPop = trackPopulation(
jamReviewRatingMapper.listAvgByJam(jamId), "gameId", "avgRating");
Map<Long, Double> popularPop = trackPopulation(
jamVotesMapper.listCountsByJam(jamId), "gameId", "voteCount");
Map<String, Integer> 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<Long, Double> population) {
if (population.isEmpty()) {
return 0;
}
List<Long> games = new ArrayList<>(population.keySet());
Comparator<Long> byScore = Comparator.comparingDouble(
(Long g) -> population.get(g)).reversed();
Comparator<Long> byOrder = byScore.thenComparing(Comparator.naturalOrder()); // game_id ASC tiebreak
Map<Long, Integer> ranks = RankScores.standardCompetitionRank(games, byOrder, byScore);
int n = 0;
for (Map.Entry<Long, Integer> 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<Long, Double> judgePop,
Map<Long, Double> ratingPop,
Map<Long, Double> popularPop) {
Map<Long, Double> judgeScore = rankScoreMap(judgePop);
Map<Long, Double> ratingScore = rankScoreMap(ratingPop);
Map<Long, Double> popularScore = rankScoreMap(popularPop);
Set<Long> grandPop = new LinkedHashSet<>();
grandPop.addAll(judgePop.keySet());
grandPop.addAll(ratingPop.keySet());
grandPop.addAll(popularPop.keySet());
if (grandPop.isEmpty()) {
return 0;
}
Map<Long, Double> 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<Long> games = new ArrayList<>(grand.keySet());
Comparator<Long> byScore = Comparator.comparingDouble(
(Long g) -> grand.get(g)).reversed();
Comparator<Long> byOrder = byScore.thenComparing(Comparator.naturalOrder());
Map<Long, Integer> ranks = RankScores.standardCompetitionRank(games, byOrder, byScore);
int n = 0;
for (Map.Entry<Long, Integer> 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<Long, Double> rankScoreMap(Map<Long, Double> population) {
if (population.isEmpty()) {
return Map.of();
}
List<Long> games = new ArrayList<>(population.keySet());
Comparator<Long> byScore = Comparator.comparingDouble(
(Long g) -> population.get(g)).reversed();
Comparator<Long> byOrder = byScore.thenComparing(Comparator.naturalOrder());
return RankScores.rankScore(games, byOrder, byScore);
}
/**
* 집계 소스 Map 리스트를 gameId -> 점수 모집단으로 변환. 점수 NULL 행은 제외(A4).
* JUDGE weighted_total NULL(채점 0) 필터 위치이기도 하다(매퍼 NULLS LAST ).
*/
private Map<Long, Double> trackPopulation(List<Map<String, Object>> rows,
String idKey, String scoreKey) {
Map<Long, Double> population = new LinkedHashMap<>();
for (Map<String, Object> 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;
}
}

View File

@ -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).
*
* <p> 메서드 모두 정렬 대상 리스트 + 점수 내림차순 비교자만 받는다(트랙/ 컨텍스트는
* 호출자가 보유 inflate 차단, design concern 1). 비교자는 점수 DESC + tiebreak(game_id ASC)
* 캡슐화하므로 정렬은 결정적이고 재산정 안정적이다.
*
* <ul>
* <li>{@link #standardCompetitionRank} 동점이면 같은 rank, 다음 순위 건너뜀(1,2,2,4).</li>
* <li>{@link #rankScore} (N - rank + 1) / N [1/N, 1]. 1위=1.0, 최하위=1/N.
* 트랙 상대 순위만 쓰므로 트랙 스케일(numeric 1~5 vs vote count 0~수백) 무관.
* GRAND 가중합 입력.</li>
* </ul>
*
* <p>동점 정의: 비교자가 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 <T> Map<T, Integer> standardCompetitionRank(List<T> items,
Comparator<T> byOrder,
Comparator<T> byScore) {
List<T> sorted = items.stream().sorted(byOrder).toList();
Map<T, Integer> 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 <T> Map<T, Double> rankScore(List<T> items,
Comparator<T> byOrder,
Comparator<T> byScore) {
int n = items.size();
Map<T, Double> scores = new LinkedHashMap<>();
if (n == 0) {
return scores;
}
Map<T, Integer> ranks = standardCompetitionRank(items, byOrder, byScore);
for (Map.Entry<T, Integer> e : ranks.entrySet()) {
scores.put(e.getKey(), (n - e.getValue() + 1) / (double) n);
}
return scores;
}
}

View File

@ -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).
*
* <p>일반 테이블 매퍼 POJO 직접 매핑(snakecamel 비인용 alias). 집계 VIEW 아니므로
* §33 큰따옴표 규칙 비적용(POJO 직접매핑은 비인용 OK).
*
* <p>재산정 멱등: deleteByJamTrack(트랙 초기화) insert(재기록) 단일 @Transactional
* 경계(JamAwardService.recompute)에서 수행. score_value 트랙별 raw 점수/GRAND 종합점수.
*
* <p>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<JamAwardData> 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<JamAwardData> listByJamWithGame(long jamId);
}

View File

@ -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).
*
* <p>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 사용.
*
* <p>F5 임계: review_count >= 3 미달 출품작 제외(부당 0점 회피). 리뷰 0개(VIEW 없음)
* LEFT JOIN 으로 NULL review_count NULL 임계 미달 제외. 6축 컬럼 미참조(단방향 G4).
*
* <p>정렬: 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<Map<String, Object>> listAvgByJam(long jamId);
}

View File

@ -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<JamEntryData> entries = (List<JamEntryData>) request.getAttribute("entries");
List<JamTeamData> teams = (List<JamTeamData>) request.getAttribute("teams");
List<JamAwardData> awardsSummary = (List<JamAwardData>) 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()) {
%>
<section class="detail-section" aria-labelledby="jam-awards">
<h2 id="jam-awards">수상 결과</h2>
<ul class="team-list">
<%
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);
%>
<li>
<span class="detail-badge"><%= trackLabel.isBlank() ? "" : HtmlUtils.htmlEscape(trackLabel) + (rankLabel.isBlank() ? "" : " " + rankLabel) %></span>
<span class="team-list__name"><%= HtmlUtils.htmlEscape(awardGameName) %></span>
</li>
<%
}
%>
</ul>
<div class="detail-actions">
<a class="detail-button" href="<%= ctx %>/jams/<%= HtmlUtils.htmlEscape(jam.getSlug() == null ? "" : jam.getSlug()) %>/results">전체 결과 보기</a>
</div>
</section>
<%
}
%>
<section class="detail-section" aria-labelledby="jam-entries">
<h2 id="jam-entries">출품작</h2>
<%

View File

@ -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<String, List<JamAwardData>> byTrack =
(Map<String, List<JamAwardData>>) request.getAttribute("byTrack");
List<JamAwardData> grand = (List<JamAwardData>) request.getAttribute("grand");
boolean noResults = (byTrack == null || byTrack.isEmpty());
%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
<title><%= jam == null ? "게임잼 결과" : HtmlUtils.htmlEscape(jam.getTitle() == null ? "게임잼 결과" : jam.getTitle()) %> 결과 | bibimbap</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--accent-soft: rgba(232, 165, 75, 0.16);
--border: rgba(0, 0, 0, 0.08);
--shadow: rgba(0, 0, 0, 0.06);
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--shadow: rgba(0, 0, 0, 0.35);
}
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
.detail-page {
max-width: 64rem;
margin: 0 auto;
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
}
.detail-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.25rem;
margin-bottom: 1rem;
color: var(--text-muted);
font-size: 0.875rem;
font-weight: 800;
text-decoration: none;
}
.detail-back:hover {
color: var(--accent);
}
.detail-hero {
margin-bottom: 1rem;
padding: 1.5rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
box-shadow: 0 2px 8px var(--shadow);
}
.detail-hero h1 {
margin: 0;
font-size: 2rem;
line-height: 1.2;
letter-spacing: 0;
}
.detail-section {
margin-bottom: 1rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
box-shadow: 0 2px 8px var(--shadow);
}
.detail-section h2 {
margin: 0 0 0.9rem;
font-size: 1.1rem;
letter-spacing: 0;
}
.empty-note {
color: var(--text-muted);
font-size: 0.9rem;
}
.award-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.award-table th,
.award-table td {
padding: 0.55rem 0.65rem;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: middle;
}
.award-table th {
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.award-table td.award-rank {
width: 3rem;
font-weight: 900;
color: var(--accent);
}
.award-table td.award-score {
text-align: right;
white-space: nowrap;
color: var(--text-muted);
}
.award-thumb {
width: 3rem;
height: 1.7rem;
object-fit: cover;
border-radius: 4px;
background: var(--accent-soft);
vertical-align: middle;
margin-right: 0.5rem;
}
.award-game {
font-weight: 800;
color: var(--text);
text-decoration: none;
}
.award-game:hover {
color: var(--accent);
}
.award-entrant {
color: var(--text-muted);
font-size: 0.82rem;
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="detail-page">
<a class="detail-back" href="<%= ctx %>/jams">← 게임잼 목록</a>
<section class="detail-hero" aria-labelledby="jam-results-title">
<h1 id="jam-results-title"><%= jam == null ? "게임잼 결과" : HtmlUtils.htmlEscape(jam.getTitle() == null ? "게임잼" : jam.getTitle()) %> 결과</h1>
</section>
<%
if (noResults) {
%>
<section class="detail-section">
<p class="empty-note">결과 준비 중입니다.</p>
</section>
<%
} else {
%>
<section class="detail-section" aria-labelledby="jam-grand">
<h2 id="jam-grand">종합 대상</h2>
<%
if (grand == null || grand.isEmpty()) {
%>
<p class="empty-note">해당 트랙 결과 없음</p>
<%
} else {
%>
<table class="award-table">
<thead>
<tr><th>순위</th><th>게임</th><th>출품자</th><th class="award-score">점수</th></tr>
</thead>
<tbody>
<%
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();
%>
<tr>
<td class="award-rank"><%= rank == null ? "-" : rank.intValue() %></td>
<td>
<%
if (thumbnailUrl != null && !thumbnailUrl.isBlank()) {
%>
<img class="award-thumb" src="<%= HtmlUtils.htmlEscape(thumbnailUrl) %>" alt="">
<%
}
if (gameId != null) {
%>
<a class="award-game" href="<%= ctx %>/game/<%= gameId %>"><%= HtmlUtils.htmlEscape(gameName) %></a>
<%
} else {
%>
<span class="award-game"><%= HtmlUtils.htmlEscape(gameName) %></span>
<%
}
%>
</td>
<td class="award-entrant"><%= entrantName == null || entrantName.isBlank() ? "" : HtmlUtils.htmlEscape(entrantName) %></td>
<td class="award-score"><%= scoreValue == null ? "" : HtmlUtils.htmlEscape(String.valueOf(scoreValue)) %></td>
</tr>
<%
}
%>
</tbody>
</table>
<%
}
%>
</section>
<%
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<JamAwardData> rows = byTrack.get(trackKey);
%>
<section class="detail-section" aria-labelledby="jam-track-<%= trackKey %>">
<h2 id="jam-track-<%= trackKey %>"><%= trackLabel %></h2>
<%
if (rows == null || rows.isEmpty()) {
%>
<p class="empty-note">해당 트랙 결과 없음</p>
<%
} else {
%>
<table class="award-table">
<thead>
<tr><th>순위</th><th>게임</th><th>출품자</th><th class="award-score">점수</th></tr>
</thead>
<tbody>
<%
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();
%>
<tr>
<td class="award-rank"><%= rank == null ? "-" : rank.intValue() %></td>
<td>
<%
if (thumbnailUrl != null && !thumbnailUrl.isBlank()) {
%>
<img class="award-thumb" src="<%= HtmlUtils.htmlEscape(thumbnailUrl) %>" alt="">
<%
}
if (gameId != null) {
%>
<a class="award-game" href="<%= ctx %>/game/<%= gameId %>"><%= HtmlUtils.htmlEscape(gameName) %></a>
<%
} else {
%>
<span class="award-game"><%= HtmlUtils.htmlEscape(gameName) %></span>
<%
}
%>
</td>
<td class="award-entrant"><%= entrantName == null || entrantName.isBlank() ? "" : HtmlUtils.htmlEscape(entrantName) %></td>
<td class="award-score"><%= scoreValue == null ? "" : HtmlUtils.htmlEscape(String.valueOf(scoreValue)) %></td>
</tr>
<%
}
%>
</tbody>
</table>
<%
}
%>
</section>
<%
}
%>
<%
}
%>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
</body>
</html>

View File

@ -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;

View File

@ -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 미사용).
*
* <p>computeAwards 게이트 순서(인증 401 인가 403 CSRF 403 잼존재 404
* F6 산정 가능 게이트 422 산정 200) 전수 검증한다. F6 게이트는
* status="CLOSED" 또는 now>evalEndAt 경우에만 통과한다.
*
* <p>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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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;
}
}

View File

@ -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 미사용).
*
* <p>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<String, List<JamAwardData>> 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<String, List<JamAwardData>> byTrack(Model model) {
return (Map<String, List<JamAwardData>>) 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;
}
}

View File

@ -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) {

View File

@ -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 미사용).
*
* <p>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) 커버한다.
*
* <p>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<String, Integer> counts = service().recompute(JAM_ID);
assertThat(counts).containsEntry("JUDGE", 3);
assertThat(counts).containsEntry("USER_RATING", 2);
assertThat(counts).containsEntry("POPULAR", 2);
List<JamAwardData> 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<JamAwardData> 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<JamAwardData> 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<String, Integer> counts = service().recompute(JAM_ID);
assertThat(counts).containsEntry("GRAND", 2);
List<JamAwardData> 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<JamAwardData> 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<String, Object> 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<String, Integer> counts = service().recompute(JAM_ID);
assertThat(counts).containsEntry("JUDGE", 1);
List<JamAwardData> all = captureInserts();
List<JamAwardData> judge = insertsForTrack(all, "JUDGE");
assertThat(judge).extracting(JamAwardData::getGameId).containsExactly(scored);
assertThat(judge).extracting(JamAwardData::getGameId).doesNotContain(unscored);
// USER_RATING 임계 미달 게임(unscored) insert 없음.
List<JamAwardData> 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<String, Integer> 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<JamAwardData> 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<JamAwardData> captureInserts() {
ArgumentCaptor<JamAwardData> captor = ArgumentCaptor.forClass(JamAwardData.class);
verify(jamAwardsMapper, org.mockito.Mockito.atLeastOnce()).insert(captor.capture());
return captor.getAllValues();
}
private List<JamAwardData> insertsForTrack(List<JamAwardData> all, String track) {
return all.stream().filter(a -> track.equals(a.getAwardTrack())).toList();
}
private int rankOf(List<JamAwardData> 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<JamAwardData> 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));
}
}