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:
parent
5ed06d9942
commit
a74bf74d13
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import com.pandoli365.bibimbap.data.JamData;
|
||||||
import com.pandoli365.bibimbap.data.JamEntryData;
|
import com.pandoli365.bibimbap.data.JamEntryData;
|
||||||
import com.pandoli365.bibimbap.data.JamTeamData;
|
import com.pandoli365.bibimbap.data.JamTeamData;
|
||||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.JamAwardsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper;
|
import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
|
import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
|
||||||
|
|
@ -38,19 +39,22 @@ public class JamController {
|
||||||
private final JamTeamsMapper jamTeamsMapper;
|
private final JamTeamsMapper jamTeamsMapper;
|
||||||
private final JamTeamMembersMapper jamTeamMembersMapper;
|
private final JamTeamMembersMapper jamTeamMembersMapper;
|
||||||
private final GamesMapper gamesMapper;
|
private final GamesMapper gamesMapper;
|
||||||
|
private final JamAwardsMapper jamAwardsMapper;
|
||||||
|
|
||||||
public JamController(
|
public JamController(
|
||||||
JamsMapper jamsMapper,
|
JamsMapper jamsMapper,
|
||||||
JamEntriesMapper jamEntriesMapper,
|
JamEntriesMapper jamEntriesMapper,
|
||||||
JamTeamsMapper jamTeamsMapper,
|
JamTeamsMapper jamTeamsMapper,
|
||||||
JamTeamMembersMapper jamTeamMembersMapper,
|
JamTeamMembersMapper jamTeamMembersMapper,
|
||||||
GamesMapper gamesMapper
|
GamesMapper gamesMapper,
|
||||||
|
JamAwardsMapper jamAwardsMapper
|
||||||
) {
|
) {
|
||||||
this.jamsMapper = jamsMapper;
|
this.jamsMapper = jamsMapper;
|
||||||
this.jamEntriesMapper = jamEntriesMapper;
|
this.jamEntriesMapper = jamEntriesMapper;
|
||||||
this.jamTeamsMapper = jamTeamsMapper;
|
this.jamTeamsMapper = jamTeamsMapper;
|
||||||
this.jamTeamMembersMapper = jamTeamMembersMapper;
|
this.jamTeamMembersMapper = jamTeamMembersMapper;
|
||||||
this.gamesMapper = gamesMapper;
|
this.gamesMapper = gamesMapper;
|
||||||
|
this.jamAwardsMapper = jamAwardsMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/jams")
|
@GetMapping("/jams")
|
||||||
|
|
@ -95,6 +99,7 @@ public class JamController {
|
||||||
model.addAttribute("jam", jam);
|
model.addAttribute("jam", jam);
|
||||||
model.addAttribute("entries", jamEntriesMapper.listByJam(jam.getId()));
|
model.addAttribute("entries", jamEntriesMapper.listByJam(jam.getId()));
|
||||||
model.addAttribute("teams", jamTeamsMapper.listByJam(jam.getId()));
|
model.addAttribute("teams", jamTeamsMapper.listByJam(jam.getId()));
|
||||||
|
model.addAttribute("awardsSummary", jamAwardsMapper.listByJamWithGame(jam.getId()));
|
||||||
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
|
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
|
||||||
return "jam-detail";
|
return "jam-detail";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 는 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 직접 매핑(snake→camel 비인용 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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,14 @@
|
||||||
<%@ page import="com.pandoli365.bibimbap.data.JamData" %>
|
<%@ page import="com.pandoli365.bibimbap.data.JamData" %>
|
||||||
<%@ page import="com.pandoli365.bibimbap.data.JamEntryData" %>
|
<%@ page import="com.pandoli365.bibimbap.data.JamEntryData" %>
|
||||||
<%@ page import="com.pandoli365.bibimbap.data.JamTeamData" %>
|
<%@ page import="com.pandoli365.bibimbap.data.JamTeamData" %>
|
||||||
|
<%@ page import="com.pandoli365.bibimbap.data.JamAwardData" %>
|
||||||
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
||||||
<%
|
<%
|
||||||
String ctx = request.getContextPath();
|
String ctx = request.getContextPath();
|
||||||
JamData jam = (JamData) request.getAttribute("jam");
|
JamData jam = (JamData) request.getAttribute("jam");
|
||||||
List<JamEntryData> entries = (List<JamEntryData>) request.getAttribute("entries");
|
List<JamEntryData> entries = (List<JamEntryData>) request.getAttribute("entries");
|
||||||
List<JamTeamData> teams = (List<JamTeamData>) request.getAttribute("teams");
|
List<JamTeamData> teams = (List<JamTeamData>) request.getAttribute("teams");
|
||||||
|
List<JamAwardData> awardsSummary = (List<JamAwardData>) request.getAttribute("awardsSummary");
|
||||||
Object rawCsrf = request.getAttribute("csrfToken");
|
Object rawCsrf = request.getAttribute("csrfToken");
|
||||||
String csrfToken = rawCsrf == null ? "" : String.valueOf(rawCsrf);
|
String csrfToken = rawCsrf == null ? "" : String.valueOf(rawCsrf);
|
||||||
String csrfTokenHtml = HtmlUtils.htmlEscape(csrfToken);
|
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">
|
<section class="detail-section" aria-labelledby="jam-entries">
|
||||||
<h2 id="jam-entries">출품작</h2>
|
<h2 id="jam-entries">출품작</h2>
|
||||||
<%
|
<%
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -5,9 +5,11 @@ import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
|
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.JamAwardsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamCriteriaMapper;
|
import com.pandoli365.bibimbap.mapper.JamCriteriaMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamJudgesMapper;
|
import com.pandoli365.bibimbap.mapper.JamJudgesMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.JamReviewRatingMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamScoreStatsMapper;
|
import com.pandoli365.bibimbap.mapper.JamScoreStatsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamScoresMapper;
|
import com.pandoli365.bibimbap.mapper.JamScoresMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamStatusLogMapper;
|
import com.pandoli365.bibimbap.mapper.JamStatusLogMapper;
|
||||||
|
|
@ -98,6 +100,12 @@ class BibimbapApplicationTests {
|
||||||
@MockBean
|
@MockBean
|
||||||
private JamVotesMapper jamVotesMapper;
|
private JamVotesMapper jamVotesMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private JamAwardsMapper jamAwardsMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private JamReviewRatingMapper jamReviewRatingMapper;
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private PermissionGate permissionGate;
|
private PermissionGate permissionGate;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import com.pandoli365.bibimbap.data.JamData;
|
||||||
import com.pandoli365.bibimbap.data.JamEntryData;
|
import com.pandoli365.bibimbap.data.JamEntryData;
|
||||||
import com.pandoli365.bibimbap.data.JamTeamData;
|
import com.pandoli365.bibimbap.data.JamTeamData;
|
||||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.JamAwardsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper;
|
import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
|
import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
|
||||||
|
|
@ -60,6 +61,9 @@ class JamControllerTest {
|
||||||
@Mock
|
@Mock
|
||||||
private GamesMapper gamesMapper;
|
private GamesMapper gamesMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private JamAwardsMapper jamAwardsMapper;
|
||||||
|
|
||||||
// ---- list (VP-4 keyset) ----
|
// ---- list (VP-4 keyset) ----
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -294,7 +298,7 @@ class JamControllerTest {
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
|
|
||||||
private JamController controller() {
|
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) {
|
private MockHttpSession userSession(long userId) {
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue