feat(hub): W3-4 메인 허브 keyset 페이징 + 진행중 잼 배너 (단계1+2 통합)

- keyset 페이징: GamesMapper.listVisibleKeyset/searchVisibleKeyset 신규 — 커서 (sort_order,created_at,id) 3-튜플 OR 분해(혼합방향 ASC/DESC/DESC), LIMIT #{limit}, #{} only. 기존 getVisibleGames/searchVisibleGames 보존(회귀 0)
- WebMvcController: cursor 파라미터 + nextCursor 산정 + parseCursor/encodeCursor(형식오류→첫페이지 폴백, throw 0) + HubCursor record + HUB_PAGE_SIZE=24. 검색/비검색 동일 커서 규약(공통 헬퍼)
- 진행중 잼 배너(단계2): JamsMapper.listActive/countActive 신규(status IN RECRUIT/DEV/EVAL, is_visible). 최신 1건 + "외 N-1개" 라벨, N=0 미렌더. CTA → W3-1 /games/search?jam={slug}
- index.jsp: 더보기 링크(hasNextPage, q+cursor URLEncoder) + 잼 배너(title HtmlUtils.htmlEscape, slug URLEncoder). 기존 렌더루프+W3-1 태그 UI 보존
- docs/games-hub-ddl.sql: idx_games_visible_keyset 멱등(games 컬럼 0변경) + schema.sql 동기
- BibimbapApplicationTests 무변경(JamsMapper/GamesMapper @MockBean W2-1 기등록 재사용)

검증: 컨테이너 ./mvnw -o test 243/243 GREEN(신규 8: WebMvcControllerTest), 회귀 0. L2 격리 throwaway DB contract PASS(혼합방향 keyset 경계 무중복/무누락·동일 sort_order/created_at tie-break·검색 keyset·잼 status 필터). 정렬키 5곳 동일·${} 0. 실 dev DB 무접촉.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
This commit is contained in:
이정수 2026-06-29 10:59:17 +09:00
parent 35f1dc3de9
commit e28fa60ca6
7 changed files with 532 additions and 9 deletions

View File

@ -105,6 +105,9 @@ ALTER SEQUENCE "games_id_seq" OWNED BY "games"."id";
CREATE INDEX IF NOT EXISTS "idx_games_visible_order"
ON "games" ("is_visible", "is_delete", "sort_order", "created_at" DESC, "id" DESC);
CREATE INDEX IF NOT EXISTS "idx_games_visible_keyset"
ON "games" ("is_visible", "is_delete", "sort_order" ASC, "created_at" DESC, "id" DESC);
-- ---------------------------------------------------------------------------
-- game_comments (비권위 복원본 — 매퍼 존재하나 컨트롤러 미연결)
-- ---------------------------------------------------------------------------

7
docs/games-hub-ddl.sql Normal file
View File

@ -0,0 +1,7 @@
-- W3-4 게임 허브 keyset 페이징 성능 인덱스. games 컬럼 변경 0(인덱스만). 멱등.
-- db/apply-local-ddl.sh 글롭 docs/*-ddl.sql 자동 적용 + db/schema.sql 동기 사본.
-- 정렬키 = sort_order ASC, created_at DESC, id DESC (GamesMapper.getVisibleGames 와 동일)
-- 인덱스 없이도 keyset 정합은 WHERE 비교가 보장하며, 인덱스는 깊은 페이지/대량 seek 최적화용이다.
CREATE INDEX IF NOT EXISTS "idx_games_visible_keyset"
ON "games" ("is_visible", "is_delete", "sort_order" ASC, "created_at" DESC, "id" DESC);

View File

@ -1,6 +1,9 @@
package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.JamData;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.JamsMapper;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
@ -13,6 +16,10 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@ -20,10 +27,14 @@ import java.util.List;
@Controller
public class WebMvcController implements WebMvcConfigurer, ErrorController {
private final GamesMapper gamesMapper;
private static final int HUB_PAGE_SIZE = 24;
public WebMvcController(GamesMapper gamesMapper) {
private final GamesMapper gamesMapper;
private final JamsMapper jamsMapper;
public WebMvcController(GamesMapper gamesMapper, JamsMapper jamsMapper) {
this.gamesMapper = gamesMapper;
this.jamsMapper = jamsMapper;
}
@RequestMapping("/error")
@ -50,8 +61,9 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
}
@GetMapping("/")
public ModelAndView indexView(@RequestParam(name = "q", required = false) String query) {
return indexModelAndView(query);
public ModelAndView indexView(@RequestParam(name = "q", required = false) String query,
@RequestParam(name = "cursor", required = false) String cursor) {
return indexModelAndView(query, cursor);
}
@GetMapping("/{pageName}")
@ -95,22 +107,80 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
mv.setViewName("operation-policy");
break;
default:
return indexModelAndView(query);
return indexModelAndView(query, null);
}
return mv;
}
private ModelAndView indexModelAndView(String query) {
private ModelAndView indexModelAndView(String query, String cursor) {
String normalizedQuery = query == null ? "" : query.trim();
HubCursor parsed = parseCursor(cursor); // null = 페이지
Integer cSort = parsed == null ? null : parsed.sortOrder();
OffsetDateTime cCreated = parsed == null ? null : parsed.createdAt();
Long cId = parsed == null ? null : parsed.id();
List<GameData> rows = normalizedQuery.isBlank()
? gamesMapper.listVisibleKeyset(cSort, cCreated, cId, HUB_PAGE_SIZE + 1)
: gamesMapper.searchVisibleKeyset(normalizedQuery, cSort, cCreated, cId, HUB_PAGE_SIZE + 1);
String nextCursor = null;
if (rows.size() > HUB_PAGE_SIZE) {
rows = new ArrayList<>(rows.subList(0, HUB_PAGE_SIZE));
GameData last = rows.get(HUB_PAGE_SIZE - 1);
nextCursor = encodeCursor(last);
}
ModelAndView mv = new ModelAndView("index");
mv.addObject("games", normalizedQuery.isBlank()
? gamesMapper.getVisibleGames()
: gamesMapper.searchVisibleGames(normalizedQuery));
mv.addObject("games", rows);
mv.addObject("searchQuery", normalizedQuery);
mv.addObject("nextCursor", nextCursor); // nullable JSP null 더보기 미표시
// 진행중 배너 (activeCount==0 attr 미주입 배너 미렌더)
int activeCount = jamsMapper.countActive();
if (activeCount > 0) {
List<JamData> active = jamsMapper.listActive(1);
if (!active.isEmpty()) {
mv.addObject("activeJam", active.get(0));
mv.addObject("activeJamCount", activeCount);
}
}
return mv;
}
private String encodeCursor(GameData last) {
if (last == null || last.getSortOrder() == null
|| last.getCreatedAt() == null || last.getId() == null) {
return null;
}
return last.getSortOrder() + "_"
+ last.getCreatedAt().toInstant().toEpochMilli() + "_"
+ last.getId();
}
// "10_1718000000000_57" HubCursor | null. 사용자 입력 신뢰 금지 형식오류는 페이지 폴백.
private HubCursor parseCursor(String cursor) {
if (cursor == null || cursor.isBlank()) {
return null;
}
String[] parts = cursor.split("_");
if (parts.length != 3) {
return null;
}
try {
int sortOrder = Integer.parseInt(parts[0]);
long millis = Long.parseLong(parts[1]);
long id = Long.parseLong(parts[2]);
OffsetDateTime createdAt = OffsetDateTime.ofInstant(
Instant.ofEpochMilli(millis), ZoneOffset.UTC);
return new HubCursor(sortOrder, createdAt, id);
} catch (NumberFormatException e) {
return null;
}
}
private record HubCursor(Integer sortOrder, OffsetDateTime createdAt, Long id) {}
private boolean isLoggedIn(HttpSession session) {
return session != null && session.getAttribute("userId") != null;
}

View File

@ -260,4 +260,84 @@ public interface GamesMapper {
</script>
""")
List<GameData> searchGamesAdvanced(SearchCriteria c);
@Select("""
<script>
SELECT
g.id,
g.user_id AS userId,
g.name,
u.display_name AS creator,
g.creator_note AS creatorNote,
g.git_url AS gitUrl,
g.webgl_path AS webglPath,
g.thumbnail_url AS thumbnailUrl,
g.like_count AS likeCount,
g.is_visible AS visible,
g.sort_order AS sortOrder,
g.created_at AS createdAt,
g.updated_at AS updatedAt
FROM games g
JOIN users u ON u.id = g.user_id
WHERE g.is_visible IS NOT FALSE
AND g.is_delete IS NOT TRUE
AND u.is_delete IS NOT TRUE
<if test="cursorSortOrder != null and cursorCreatedAt != null and cursorId != null">
AND (
g.sort_order &gt; #{cursorSortOrder}
OR (g.sort_order = #{cursorSortOrder} AND g.created_at &lt; #{cursorCreatedAt})
OR (g.sort_order = #{cursorSortOrder} AND g.created_at = #{cursorCreatedAt} AND g.id &lt; #{cursorId})
)
</if>
ORDER BY g.sort_order ASC, g.created_at DESC, g.id DESC
LIMIT #{limit}
</script>
""")
List<GameData> listVisibleKeyset(@Param("cursorSortOrder") Integer cursorSortOrder,
@Param("cursorCreatedAt") java.time.OffsetDateTime cursorCreatedAt,
@Param("cursorId") Long cursorId,
@Param("limit") int limit);
@Select("""
<script>
SELECT
g.id,
g.user_id AS userId,
g.name,
u.display_name AS creator,
g.creator_note AS creatorNote,
g.git_url AS gitUrl,
g.webgl_path AS webglPath,
g.thumbnail_url AS thumbnailUrl,
g.like_count AS likeCount,
g.is_visible AS visible,
g.sort_order AS sortOrder,
g.created_at AS createdAt,
g.updated_at AS updatedAt
FROM games g
JOIN users u ON u.id = g.user_id
WHERE g.is_visible IS NOT FALSE
AND g.is_delete IS NOT TRUE
AND u.is_delete IS NOT TRUE
AND (
g.name ILIKE CONCAT('%', #{query}, '%')
OR u.display_name ILIKE CONCAT('%', #{query}, '%')
OR g.creator_note ILIKE CONCAT('%', #{query}, '%')
)
<if test="cursorSortOrder != null and cursorCreatedAt != null and cursorId != null">
AND (
g.sort_order &gt; #{cursorSortOrder}
OR (g.sort_order = #{cursorSortOrder} AND g.created_at &lt; #{cursorCreatedAt})
OR (g.sort_order = #{cursorSortOrder} AND g.created_at = #{cursorCreatedAt} AND g.id &lt; #{cursorId})
)
</if>
ORDER BY g.sort_order ASC, g.created_at DESC, g.id DESC
LIMIT #{limit}
</script>
""")
List<GameData> searchVisibleKeyset(@Param("query") String query,
@Param("cursorSortOrder") Integer cursorSortOrder,
@Param("cursorCreatedAt") java.time.OffsetDateTime cursorCreatedAt,
@Param("cursorId") Long cursorId,
@Param("limit") int limit);
}

View File

@ -181,4 +181,36 @@ public interface JamsMapper {
AND is_delete IS NOT TRUE
""")
int softDelete(long jamId);
@Select("""
SELECT id, slug, title, description, status,
recruit_start_at AS recruitStartAt,
dev_start_at AS devStartAt,
eval_start_at AS evalStartAt,
eval_end_at AS evalEndAt,
discord_url AS discordUrl,
prize_info AS prizeInfo,
sponsor_info AS sponsorInfo,
is_visible AS isVisible,
created_by AS createdBy,
created_at AS createdAt,
updated_at AS updatedAt,
is_delete AS isDelete
FROM jams
WHERE status IN ('RECRUIT', 'DEV', 'EVAL')
AND is_visible IS NOT FALSE
AND is_delete IS NOT TRUE
ORDER BY created_at DESC, id DESC
LIMIT #{limit}
""")
List<JamData> listActive(@Param("limit") int limit);
@Select("""
SELECT COUNT(*)
FROM jams
WHERE status IN ('RECRUIT', 'DEV', 'EVAL')
AND is_visible IS NOT FALSE
AND is_delete IS NOT TRUE
""")
int countActive();
}

View File

@ -66,6 +66,25 @@
}
}
}
String nextCursor = "";
Object nextCursorAttr = request.getAttribute("nextCursor");
if (nextCursorAttr instanceof String) {
nextCursor = ((String) nextCursorAttr).trim();
}
boolean hasNextPage = !nextCursor.isBlank();
com.pandoli365.bibimbap.data.JamData activeJam = null;
Object activeJamAttr = request.getAttribute("activeJam");
if (activeJamAttr instanceof com.pandoli365.bibimbap.data.JamData) {
activeJam = (com.pandoli365.bibimbap.data.JamData) activeJamAttr;
}
int activeJamCount = 0;
Object activeJamCountAttr = request.getAttribute("activeJamCount");
if (activeJamCountAttr instanceof Integer) {
activeJamCount = (Integer) activeJamCountAttr;
}
boolean showJamBanner = activeJamCount > 0 && activeJam != null && activeJam.getSlug() != null && !activeJam.getSlug().isBlank();
%>
<!DOCTYPE html>
<html lang="ko">
@ -585,6 +604,83 @@
text-align: center;
line-height: 1.6;
}
.jam-banner {
width: min(100%, 48rem);
margin: 0 auto 1.25rem;
}
.jam-banner__link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid rgba(232, 165, 75, 0.4);
border-radius: 12px;
background: var(--card-bg);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
text-decoration: none;
color: var(--text);
}
.jam-banner__link:hover {
border-color: rgba(232, 165, 75, 0.7);
box-shadow: 0 6px 16px var(--card-shadow);
}
.jam-banner__badge {
flex-shrink: 0;
padding: 0.25rem 0.55rem;
font-size: 0.6875rem;
font-weight: 800;
color: #1a1a1a;
background: var(--accent);
border-radius: 6px;
}
.jam-banner__text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.jam-banner__title {
font-size: 0.9375rem;
font-weight: 700;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jam-banner__meta {
font-size: 0.75rem;
color: var(--text-muted);
}
.jam-banner__arrow {
flex-shrink: 0;
font-size: 1.1rem;
color: var(--accent);
}
.hub-more {
display: flex;
justify-content: center;
margin-top: 1.5rem;
}
.hub-more__link {
min-height: 3rem;
padding: 0 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
color: var(--text);
font-size: 0.9375rem;
font-weight: 700;
text-decoration: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.hub-more__link:hover {
border-color: rgba(232, 165, 75, 0.5);
box-shadow: 0 6px 16px var(--card-shadow);
}
</style>
</head>
<body>
@ -670,6 +766,23 @@
</div>
</section>
<% if (showJamBanner) {
String jamTitle = HtmlUtils.htmlEscape(activeJam.getTitle() == null || activeJam.getTitle().isBlank() ? "게임잼" : activeJam.getTitle());
String jamSlugParam = java.net.URLEncoder.encode(activeJam.getSlug(), java.nio.charset.StandardCharsets.UTF_8);
String jamCountLabel = activeJamCount > 1 ? ("외 " + (activeJamCount - 1) + "개 진행 중") : "진행 중";
%>
<section class="jam-banner" aria-label="진행 중인 게임잼">
<a class="jam-banner__link" href="<%= ctx %>/games/search?jam=<%= jamSlugParam %>">
<span class="jam-banner__badge">게임잼</span>
<span class="jam-banner__text">
<strong class="jam-banner__title"><%= jamTitle %></strong>
<span class="jam-banner__meta"><%= jamCountLabel %> · 출품작 보러가기</span>
</span>
<span class="jam-banner__arrow" aria-hidden="true">&rarr;</span>
</a>
</section>
<% } %>
<section class="card-grid" aria-label="추천 목록">
<% if (games.isEmpty()) { %>
<% if (searching) { %>
@ -717,6 +830,18 @@
}
%>
</section>
<% if (hasNextPage) {
String moreHref = ctx + "/?";
if (searching) {
moreHref += "q=" + java.net.URLEncoder.encode(searchQuery, java.nio.charset.StandardCharsets.UTF_8) + "&";
}
moreHref += "cursor=" + java.net.URLEncoder.encode(nextCursor, java.nio.charset.StandardCharsets.UTF_8);
%>
<nav class="hub-more" aria-label="더 많은 게임">
<a class="hub-more__link" href="<%= moreHref %>">더 보기</a>
</nav>
<% } %>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
<script>

View File

@ -0,0 +1,206 @@
package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.JamData;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.JamsMapper;
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 org.springframework.web.servlet.ModelAndView;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class WebMvcControllerTest {
private static final int HUB_PAGE_SIZE = 24;
private static final int FETCH_LIMIT = HUB_PAGE_SIZE + 1;
@Mock
private GamesMapper gamesMapper;
@Mock
private JamsMapper jamsMapper;
@Test
void firstPage_returnsPageSizeAndNextCursor() {
when(jamsMapper.countActive()).thenReturn(0);
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
.thenReturn(games(FETCH_LIMIT));
ModelAndView mv = controller().indexView(null, null);
assertThat(mv.getViewName()).isEqualTo("index");
List<?> games = (List<?>) mv.getModel().get("games");
assertThat(games).hasSize(HUB_PAGE_SIZE);
// nextCursor 잘린 24건 마지막(index 23) 기준이어야 한다.
GameData boundary = (GameData) games.get(HUB_PAGE_SIZE - 1);
String expected = boundary.getSortOrder() + "_"
+ boundary.getCreatedAt().toInstant().toEpochMilli() + "_"
+ boundary.getId();
assertThat(mv.getModel().get("nextCursor")).isEqualTo(expected);
}
@Test
void firstPage_noNextCursor_whenResultsAtOrBelowPageSize() {
when(jamsMapper.countActive()).thenReturn(0);
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
.thenReturn(games(HUB_PAGE_SIZE));
ModelAndView mv = controller().indexView(null, null);
List<?> games = (List<?>) mv.getModel().get("games");
assertThat(games).hasSize(HUB_PAGE_SIZE);
assertThat(mv.getModel().get("nextCursor")).isNull();
}
@Test
void secondPage_parsesCursorAndPassesToMapper() {
when(jamsMapper.countActive()).thenReturn(0);
when(gamesMapper.listVisibleKeyset(any(), any(), any(), eq(FETCH_LIMIT)))
.thenReturn(new ArrayList<>());
controller().indexView(null, "10_1718000000000_57");
ArgumentCaptor<Integer> sortCaptor = ArgumentCaptor.forClass(Integer.class);
ArgumentCaptor<OffsetDateTime> createdCaptor = ArgumentCaptor.forClass(OffsetDateTime.class);
ArgumentCaptor<Long> idCaptor = ArgumentCaptor.forClass(Long.class);
ArgumentCaptor<Integer> limitCaptor = ArgumentCaptor.forClass(Integer.class);
verify(gamesMapper).listVisibleKeyset(
sortCaptor.capture(), createdCaptor.capture(), idCaptor.capture(), limitCaptor.capture());
assertThat(sortCaptor.getValue()).isEqualTo(10);
assertThat(idCaptor.getValue()).isEqualTo(57L);
assertThat(createdCaptor.getValue().toInstant().toEpochMilli()).isEqualTo(1718000000000L);
assertThat(limitCaptor.getValue()).isEqualTo(FETCH_LIMIT);
}
@Test
void malformedCursor_fallsBackToFirstPage() {
when(jamsMapper.countActive()).thenReturn(0);
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
.thenReturn(new ArrayList<>());
WebMvcController controller = controller();
// 각각: 구분자 없음 / 컴포넌트 3 미만 / 숫자 파싱 실패 모두 페이지 폴백, 예외 없음.
controller.indexView(null, "garbage");
controller.indexView(null, "1_2");
controller.indexView(null, "x_y_z");
verify(gamesMapper, never()).listVisibleKeyset(
any(Integer.class), any(OffsetDateTime.class), any(Long.class), anyInt());
}
@Test
void searchQuery_usesSearchKeysetAndPreservesQuery() {
when(jamsMapper.countActive()).thenReturn(0);
when(gamesMapper.searchVisibleKeyset(eq("플랫폼"), isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
.thenReturn(games(FETCH_LIMIT));
ModelAndView mv = controller().indexView("플랫폼", null);
assertThat(mv.getModel().get("searchQuery")).isEqualTo("플랫폼");
assertThat(mv.getModel().get("nextCursor")).isNotNull();
verify(gamesMapper).searchVisibleKeyset("플랫폼", null, null, null, FETCH_LIMIT);
verify(gamesMapper, never()).listVisibleKeyset(any(), any(), any(), anyInt());
}
@Test
void searchQuery_withCursor_passesBoth() {
when(jamsMapper.countActive()).thenReturn(0);
when(gamesMapper.searchVisibleKeyset(eq("플랫폼"), any(), any(), any(), eq(FETCH_LIMIT)))
.thenReturn(new ArrayList<>());
controller().indexView("플랫폼", "10_1718000000000_57");
ArgumentCaptor<String> queryCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Integer> sortCaptor = ArgumentCaptor.forClass(Integer.class);
ArgumentCaptor<OffsetDateTime> createdCaptor = ArgumentCaptor.forClass(OffsetDateTime.class);
ArgumentCaptor<Long> idCaptor = ArgumentCaptor.forClass(Long.class);
ArgumentCaptor<Integer> limitCaptor = ArgumentCaptor.forClass(Integer.class);
verify(gamesMapper).searchVisibleKeyset(
queryCaptor.capture(), sortCaptor.capture(), createdCaptor.capture(),
idCaptor.capture(), limitCaptor.capture());
assertThat(queryCaptor.getValue()).isEqualTo("플랫폼");
assertThat(sortCaptor.getValue()).isEqualTo(10);
assertThat(idCaptor.getValue()).isEqualTo(57L);
assertThat(limitCaptor.getValue()).isEqualTo(FETCH_LIMIT);
}
@Test
void banner_renderedWhenActiveJamExists() {
when(jamsMapper.countActive()).thenReturn(3);
when(jamsMapper.listActive(1)).thenReturn(List.of(jam("summer-jam", "여름 게임잼")));
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
.thenReturn(new ArrayList<>());
ModelAndView mv = controller().indexView(null, null);
assertThat(mv.getModel().get("activeJam")).isNotNull();
assertThat(mv.getModel().get("activeJamCount")).isEqualTo(3);
}
@Test
void banner_notRendered_whenNoActiveJam() {
when(jamsMapper.countActive()).thenReturn(0);
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
.thenReturn(new ArrayList<>());
ModelAndView mv = controller().indexView(null, null);
assertThat(mv.getModel().get("activeJam")).isNull();
assertThat(mv.getModel().get("activeJamCount")).isNull();
verify(jamsMapper, never()).listActive(anyInt());
}
// ---- helpers ----
private WebMvcController controller() {
return new WebMvcController(gamesMapper, jamsMapper);
}
private List<GameData> games(int count) {
List<GameData> rows = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
// id/sortOrder/createdAt 모두 유일한 값으로 채워 nextCursor 인코딩이 행마다 구분되게 한다.
rows.add(game((long) (i + 1), i + 1, 1_700_000_000_000L + i));
}
return rows;
}
private GameData game(long id, int sortOrder, long createdAtMillis) {
GameData g = new GameData();
g.setId(id);
g.setSortOrder(sortOrder);
g.setCreatedAt(OffsetDateTime.ofInstant(Instant.ofEpochMilli(createdAtMillis), ZoneOffset.UTC));
g.setName("game-" + id);
return g;
}
private JamData jam(String slug, String title) {
JamData j = new JamData();
j.setSlug(slug);
j.setTitle(title);
j.setStatus("RECRUIT");
j.setCreatedAt(OffsetDateTime.ofInstant(Instant.ofEpochMilli(1_700_000_000_000L), ZoneOffset.UTC));
return j;
}
}