From a4296b1bb5c54f1b3b8f9c3d3dca8fc2c6bdd78d Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Mon, 24 Aug 2026 22:00:41 +0900 Subject: [PATCH 01/14] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=9E=84=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=9D=B4=EB=A0=A5=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/GameDuplicateCheckQueryService.java | 26 ++---- .../calculator/GamePairHistoryCalculator.java | 81 +++++++++++++++++++ .../GameDuplicateCheckQueryServiceTest.java | 9 ++- .../GamePairHistoryCalculatorTest.java | 81 +++++++++++++++++++ 4 files changed, 175 insertions(+), 22 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculator.java create mode 100644 src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculatorTest.java diff --git a/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java b/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java index f6fb9a9be..932c32b8b 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java @@ -11,13 +11,12 @@ import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; import umc.cockple.demo.domain.game.repository.GameRepository; import umc.cockple.demo.domain.game.service.query.result.GameDuplicateCheckResult; +import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator; +import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator.GamePairHistory; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; /** * 게임 중복 체크 @@ -32,6 +31,7 @@ public class GameDuplicateCheckQueryService { private final GameBoardReader gameBoardReader; private final GameRepository gameRepository; private final GameBoardMemberRepository gameBoardMemberRepository; + private final GamePairHistoryCalculator gamePairHistoryCalculator; /** * @param memberId 요청자(조회는 인증된 회원이면 누구나 가능 — 권한 제한 없음) @@ -44,24 +44,15 @@ public GameDuplicateCheckResult checkDuplicates(Long memberId, Long gameBoardId, List completedGames = gameRepository .findByGameBoardIdAndStatusInWithPlayers(gameBoard.getId(), COMPLETED_ONLY); - List> gameMemberSets = completedGames.stream() - .map(this::memberIdsOf) - .toList(); - Set lastGameMemberIds = completedGames.stream() - .max(Comparator.comparing(Game::getCompletedAt)) - .map(this::memberIdsOf) - .orElse(Set.of()); + GamePairHistory pairHistory = gamePairHistoryCalculator.calculate(completedGames); List pairs = new ArrayList<>(); for (int i = 0; i < targetMemberIds.size(); i++) { for (int j = i + 1; j < targetMemberIds.size(); j++) { Long memberIdA = targetMemberIds.get(i); Long memberIdB = targetMemberIds.get(j); - int count = (int) gameMemberSets.stream() - .filter(members -> members.contains(memberIdA) && members.contains(memberIdB)) - .count(); - boolean playedInLastGame = lastGameMemberIds.contains(memberIdA) - && lastGameMemberIds.contains(memberIdB); + int count = pairHistory.count(memberIdA, memberIdB); + boolean playedInLastGame = pairHistory.playedInLastGame(memberIdA, memberIdB); pairs.add(new GameDuplicateCheckResult.PairView(memberIdA, memberIdB, count, playedInLastGame)); } } @@ -75,9 +66,4 @@ private void validateMembersBelongToBoard(Long gameBoardId, List memberIds } } - private Set memberIdsOf(Game game) { - return game.getPlayers().stream() - .map(player -> player.getGameBoardMember().getId()) - .collect(Collectors.toSet()); - } } diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculator.java b/src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculator.java new file mode 100644 index 000000000..31c83becb --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculator.java @@ -0,0 +1,81 @@ +package umc.cockple.demo.domain.game.service.support.calculator; + +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.game.domain.Game; +import umc.cockple.demo.domain.game.domain.GamePlayer; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component +public class GamePairHistoryCalculator { + + public GamePairHistory calculate(List completedGames) { + Map completedGameCounts = new HashMap<>(); + for (Game completedGame : completedGames) { + for (MemberPair pair : pairsOf(completedGame)) { + completedGameCounts.merge(pair, 1, Integer::sum); + } + } + + Set lastGamePairs = completedGames.stream() + .max(Comparator.comparing(Game::getCompletedAt)) + .map(this::pairsOf) + .map(Set::copyOf) + .orElseGet(Set::of); + + return new GamePairHistory(Map.copyOf(completedGameCounts), lastGamePairs); + } + + private List pairsOf(Game game) { + List memberIds = game.getPlayers().stream() + .map(GamePlayer::getGameBoardMember) + .map(gameBoardMember -> gameBoardMember.getId()) + .distinct() + .sorted() + .toList(); + + List pairs = new ArrayList<>(); + for (int i = 0; i < memberIds.size(); i++) { + for (int j = i + 1; j < memberIds.size(); j++) { + pairs.add(MemberPair.of(memberIds.get(i), memberIds.get(j))); + } + } + return pairs; + } + + public static final class GamePairHistory { + + private final Map completedGameCounts; + private final Set lastGamePairs; + + private GamePairHistory( + Map completedGameCounts, + Set lastGamePairs) { + this.completedGameCounts = completedGameCounts; + this.lastGamePairs = lastGamePairs; + } + + public int count(Long memberIdA, Long memberIdB) { + return completedGameCounts.getOrDefault(MemberPair.of(memberIdA, memberIdB), 0); + } + + public boolean playedInLastGame(Long memberIdA, Long memberIdB) { + return lastGamePairs.contains(MemberPair.of(memberIdA, memberIdB)); + } + } + + private record MemberPair(Long lowerMemberId, Long higherMemberId) { + + private static MemberPair of(Long memberIdA, Long memberIdB) { + if (memberIdA.compareTo(memberIdB) <= 0) { + return new MemberPair(memberIdA, memberIdB); + } + return new MemberPair(memberIdB, memberIdA); + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java index 5f39c0fd2..6c87caa8d 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java @@ -4,7 +4,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import umc.cockple.demo.domain.game.domain.GameBoard; @@ -15,6 +14,7 @@ import umc.cockple.demo.domain.game.repository.GameRepository; import umc.cockple.demo.domain.game.service.query.result.GameDuplicateCheckResult; import umc.cockple.demo.domain.game.service.query.result.GameDuplicateCheckResult.PairView; +import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.support.fixture.GameFixture; @@ -36,7 +36,7 @@ class GameDuplicateCheckQueryServiceTest { @Mock private GameRepository gameRepository; @Mock private GameBoardMemberRepository gameBoardMemberRepository; - @InjectMocks private GameDuplicateCheckQueryService gameDuplicateCheckQueryService; + private GameDuplicateCheckQueryService gameDuplicateCheckQueryService; private static final Long MEMBER_ID = 100L; private static final Long BOARD_ID = 1L; @@ -45,6 +45,11 @@ class GameDuplicateCheckQueryServiceTest { @BeforeEach void setUp() { board = GameFixture.gameBoard(BOARD_ID); + gameDuplicateCheckQueryService = new GameDuplicateCheckQueryService( + gameBoardReader, + gameRepository, + gameBoardMemberRepository, + new GamePairHistoryCalculator()); } @Test diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculatorTest.java b/src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculatorTest.java new file mode 100644 index 000000000..876805e52 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculatorTest.java @@ -0,0 +1,81 @@ +package umc.cockple.demo.domain.game.service.support.calculator; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import umc.cockple.demo.domain.game.domain.Game; +import umc.cockple.demo.domain.game.domain.GameBoard; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator.GamePairHistory; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.GameFixture; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("GamePairHistoryCalculator") +class GamePairHistoryCalculatorTest { + + private final GamePairHistoryCalculator calculator = new GamePairHistoryCalculator(); + + @Test + @DisplayName("완료 게임의 모든 쌍별 횟수와 직전 경기 포함 여부를 계산한다") + void calculate_countsCompletedGamesAndLastGamePairs() { + GameBoard gameBoard = GameFixture.gameBoard(1L); + GameBoardMember first = GameFixture.member(1L, gameBoard, "첫 번째", Level.A); + GameBoardMember second = GameFixture.member(2L, gameBoard, "두 번째", Level.A); + GameBoardMember third = GameFixture.member(3L, gameBoard, "세 번째", Level.A); + GameBoardMember fourth = GameFixture.member(4L, gameBoard, "네 번째", Level.A); + Game earlier = GameFixture.completedGame( + 1L, + gameBoard, + LocalDateTime.of(2026, 8, 21, 10, 0), + GameFixture.player(first, 0), + GameFixture.player(second, 1), + GameFixture.player(third, 2)); + Game last = GameFixture.completedGame( + 2L, + gameBoard, + LocalDateTime.of(2026, 8, 21, 11, 0), + GameFixture.player(first, 0), + GameFixture.player(second, 1), + GameFixture.player(fourth, 2)); + + GamePairHistory history = calculator.calculate(List.of(earlier, last)); + + assertThat(history.count(first.getId(), second.getId())).isEqualTo(2); + assertThat(history.count(first.getId(), third.getId())).isEqualTo(1); + assertThat(history.count(third.getId(), fourth.getId())).isZero(); + assertThat(history.playedInLastGame(first.getId(), second.getId())).isTrue(); + assertThat(history.playedInLastGame(first.getId(), third.getId())).isFalse(); + } + + @Test + @DisplayName("멤버 ID 순서와 관계없이 같은 쌍의 이력을 반환한다") + void calculate_normalizesMemberIdOrder() { + GameBoard gameBoard = GameFixture.gameBoard(1L); + GameBoardMember first = GameFixture.member(1L, gameBoard, "첫 번째", Level.A); + GameBoardMember second = GameFixture.member(2L, gameBoard, "두 번째", Level.A); + Game completed = GameFixture.completedGame( + 1L, + gameBoard, + LocalDateTime.of(2026, 8, 21, 10, 0), + GameFixture.player(first, 0), + GameFixture.player(second, 1)); + + GamePairHistory history = calculator.calculate(List.of(completed)); + + assertThat(history.count(second.getId(), first.getId())).isEqualTo(1); + assertThat(history.playedInLastGame(second.getId(), first.getId())).isTrue(); + } + + @Test + @DisplayName("완료 게임이 없으면 쌍 이력은 비어 있다") + void calculate_returnsEmptyHistoryWithoutCompletedGames() { + GamePairHistory history = calculator.calculate(List.of()); + + assertThat(history.count(1L, 2L)).isZero(); + assertThat(history.playedInLastGame(1L, 2L)).isFalse(); + } +} From 0a51bd5f53348579a3fd3d10f1ac8ce71dbe9eeb Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Mon, 24 Aug 2026 22:25:19 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=EC=84=A0=EC=88=98=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=EA=B5=B0=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B0=80?= =?UTF-8?q?=EC=9A=A9=EC=84=B1=20=EC=A0=95=EC=B1=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GameBoardMemberAvailabilityPolicy.java | 62 +++++++++++++ ...GameBoardMemberAvailabilityPolicyTest.java | 88 +++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 src/main/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicy.java create mode 100644 src/test/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicyTest.java diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicy.java b/src/main/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicy.java new file mode 100644 index 000000000..7054f0ce7 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicy.java @@ -0,0 +1,62 @@ +package umc.cockple.demo.domain.game.service.support.validator; + +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.game.domain.Game; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.domain.GamePlayer; +import umc.cockple.demo.domain.game.enums.GameStatus; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component +public class GameBoardMemberAvailabilityPolicy { + + private static final long PLAYING_COOLDOWN_MINUTES = 10; + + public List filterAvailable( + List members, + List activeGames, + LocalDateTime now) { + Set blockedMemberIds = blockedMemberIds(activeGames, now); + return members.stream() + .filter(member -> Boolean.TRUE.equals(member.getParticipating())) + .filter(member -> !blockedMemberIds.contains(member.getId())) + .toList(); + } + + public boolean hasBlockedMember( + List members, + List activeGames, + LocalDateTime now) { + Set blockedMemberIds = blockedMemberIds(activeGames, now); + return members.stream().anyMatch(member -> blockedMemberIds.contains(member.getId())); + } + + private Set blockedMemberIds(List activeGames, LocalDateTime now) { + Set blockedMemberIds = new HashSet<>(); + for (Game game : activeGames) { + if (!blocksSelection(game, now)) { + continue; + } + game.getPlayers().stream() + .map(GamePlayer::getGameBoardMember) + .map(GameBoardMember::getId) + .forEach(blockedMemberIds::add); + } + return blockedMemberIds; + } + + private boolean blocksSelection(Game game, LocalDateTime now) { + if (game.getStatus() == GameStatus.WAITING) { + return true; + } + if (game.getStatus() != GameStatus.PLAYING) { + return false; + } + LocalDateTime startedAt = game.getStartedAt(); + return startedAt == null || startedAt.isAfter(now.minusMinutes(PLAYING_COOLDOWN_MINUTES)); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicyTest.java b/src/test/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicyTest.java new file mode 100644 index 000000000..a0c1141f6 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicyTest.java @@ -0,0 +1,88 @@ +package umc.cockple.demo.domain.game.service.support.validator; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import umc.cockple.demo.domain.game.domain.Game; +import umc.cockple.demo.domain.game.domain.GameBoard; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.GameFixture; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("GameBoardMemberAvailabilityPolicy") +class GameBoardMemberAvailabilityPolicyTest { + + private final GameBoardMemberAvailabilityPolicy policy = + new GameBoardMemberAvailabilityPolicy(); + + private final GameBoard board = GameFixture.gameBoard(1L); + private final LocalDateTime now = LocalDateTime.of(2026, 8, 24, 20, 0); + + @Test + @DisplayName("불참 선수와 WAITING 선수는 제외한다") + void filterAvailable_excludesInactiveAndWaitingMembers() { + GameBoardMember inactive = GameFixture.member(1L, board, "불참", Level.A); + inactive.changeParticipation(false); + GameBoardMember waiting = GameFixture.member(2L, board, "대기", Level.A); + GameBoardMember available = GameFixture.member(3L, board, "가용", Level.A); + Game waitingGame = GameFixture.waitingGame( + 1L, board, 1, GameFixture.player(waiting, 0)); + + List result = policy.filterAvailable( + List.of(inactive, waiting, available), List.of(waitingGame), now); + + assertThat(result).containsExactly(available); + } + + @Test + @DisplayName("PLAYING 시작 후 10분 미만이거나 시작 시각이 없으면 제외한다") + void filterAvailable_excludesRecentOrUnknownPlayingMembers() { + GameBoardMember recent = GameFixture.member(1L, board, "최근", Level.A); + GameBoardMember unknown = GameFixture.member(2L, board, "시각없음", Level.A); + Game recentGame = GameFixture.playingGame( + 1L, board, null, now.minusMinutes(9), GameFixture.player(recent, 0)); + Game unknownGame = GameFixture.playingGame( + 2L, board, null, null, GameFixture.player(unknown, 0)); + + List result = policy.filterAvailable( + List.of(recent, unknown), List.of(recentGame, unknownGame), now); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("PLAYING 시작 후 정확히 10분 이상이면 다시 후보가 된다") + void filterAvailable_includesPlayingMembersAfterTenMinutes() { + GameBoardMember exact = GameFixture.member(1L, board, "정확히10분", Level.A); + GameBoardMember older = GameFixture.member(2L, board, "10분초과", Level.A); + Game exactGame = GameFixture.playingGame( + 1L, board, null, now.minusMinutes(10), GameFixture.player(exact, 0)); + Game olderGame = GameFixture.playingGame( + 2L, board, null, now.minusMinutes(11), GameFixture.player(older, 0)); + + List result = policy.filterAvailable( + List.of(exact, older), List.of(exactGame, olderGame), now); + + assertThat(result).containsExactly(exact, older); + } + + @Test + @DisplayName("여러 활성 게임 중 하나라도 차단 조건이면 제외한다") + void filterAvailable_excludesMemberWhenAnyActiveGameBlocksSelection() { + GameBoardMember member = GameFixture.member(1L, board, "중복", Level.A); + Game oldPlaying = GameFixture.playingGame( + 1L, board, null, now.minusMinutes(20), GameFixture.player(member, 0)); + Game waiting = GameFixture.waitingGame( + 2L, board, 1, GameFixture.player(member, 0)); + + List result = policy.filterAvailable( + List.of(member), List.of(oldPlaying, waiting), now); + + assertThat(result).isEmpty(); + assertThat(policy.hasBlockedMember(List.of(member), List.of(oldPlaying, waiting), now)).isTrue(); + } +} From 0e6401487342765df9c4b58417fcb1cb0044affb Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Mon, 24 Aug 2026 22:30:19 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=EB=8C=80=EA=B8=B0=20=EA=B2=8C?= =?UTF-8?q?=EC=9E=84=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EB=9E=9C=EB=8D=A4?= =?UTF-8?q?=20=EB=A7=A4=EC=B9=AD=EC=9D=98=20=EC=A0=95=EC=B1=85=EC=9D=84=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/game/exception/GameErrorCode.java | 3 +- .../service/command/GameCommandService.java | 11 ++++++ .../command/GameCommandServiceTest.java | 36 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java b/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java index bb5b68bfb..e7abf4302 100644 --- a/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java +++ b/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java @@ -32,7 +32,8 @@ public enum GameErrorCode implements BaseErrorCode { INVALID_CURSOR(HttpStatus.BAD_REQUEST, "GAME412", "커서 형식이 올바르지 않습니다."), ACTIVE_GAME_MEMBER_CANNOT_BE_INACTIVE(HttpStatus.BAD_REQUEST, "GAME413", "진행 또는 대기 중인 게임에 포함된 선수는 참여 해제할 수 없습니다."), INACTIVE_GAME_PLAYER(HttpStatus.BAD_REQUEST, "GAME414", "불참 상태의 선수는 게임에 추가할 수 없습니다."), - INVALID_COURT_COUNT(HttpStatus.BAD_REQUEST, "GAME415", "코트는 최소 1개, 최대 10개까지 관리할 수 있습니다.") + INVALID_COURT_COUNT(HttpStatus.BAD_REQUEST, "GAME415", "코트는 최소 1개, 최대 10개까지 관리할 수 있습니다."), + UNAVAILABLE_GAME_PLAYER(HttpStatus.BAD_REQUEST, "GAME418", "현재 선택할 수 없는 선수가 포함되어 있습니다.") ; diff --git a/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java b/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java index c4c48d33b..5633b5790 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java @@ -25,6 +25,7 @@ import umc.cockple.demo.domain.game.service.command.result.GameDeleteResult; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; import umc.cockple.demo.domain.game.service.support.validator.GameBoardAccessValidator; +import umc.cockple.demo.domain.game.service.support.validator.GameBoardMemberAvailabilityPolicy; import java.time.LocalDateTime; import java.util.List; @@ -38,11 +39,15 @@ @Slf4j public class GameCommandService { + private static final List ACTIVE_STATUSES = + List.of(GameStatus.WAITING, GameStatus.PLAYING); + private final GameBoardReader gameBoardReader; private final GameRepository gameRepository; private final CourtRepository courtRepository; private final GameBoardMemberRepository gameBoardMemberRepository; private final GameBoardAccessValidator gameBoardAccessValidator; + private final GameBoardMemberAvailabilityPolicy availabilityPolicy; private final ApplicationEventPublisher eventPublisher; /** @@ -65,6 +70,12 @@ public Long createGame(Long memberId, GameCreateCommand command) { .anyMatch(gameBoardMember -> !Boolean.TRUE.equals(gameBoardMember.getParticipating()))) { throw new GameException(GameErrorCode.INACTIVE_GAME_PLAYER); } + List activeGames = gameRepository.findByGameBoardIdAndStatusInWithPlayers( + gameBoard.getId(), ACTIVE_STATUSES); + if (availabilityPolicy.hasBlockedMember( + List.copyOf(membersById.values()), activeGames, LocalDateTime.now())) { + throw new GameException(GameErrorCode.UNAVAILABLE_GAME_PLAYER); + } int nextWaitingOrder = (int) gameRepository .countByGameBoardIdAndStatus(gameBoard.getId(), GameStatus.WAITING) + 1; diff --git a/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java index 402895b89..a8ad9b525 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java @@ -30,6 +30,7 @@ import umc.cockple.demo.domain.game.service.command.result.GameDeleteResult; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; import umc.cockple.demo.domain.game.service.support.validator.GameBoardAccessValidator; +import umc.cockple.demo.domain.game.service.support.validator.GameBoardMemberAvailabilityPolicy; import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.support.fixture.GameFixture; @@ -55,6 +56,7 @@ class GameCommandServiceTest { @Mock private CourtRepository courtRepository; @Mock private GameBoardMemberRepository gameBoardMemberRepository; @Mock private GameBoardAccessValidator gameBoardAccessValidator; + @Mock private GameBoardMemberAvailabilityPolicy availabilityPolicy; @Mock private ApplicationEventPublisher eventPublisher; @InjectMocks private GameCommandService gameCommandService; @@ -273,6 +275,40 @@ MEMBER_ID, new GameCreateCommand(BOARD_ID, List.of(7L)))) then(eventPublisher).should(never()).publishEvent(any()); } + @Test + @DisplayName("WAITING 또는 10분 미만 PLAYING 선수가 포함되면 UNAVAILABLE_GAME_PLAYER 예외") + void createGame_unavailablePlayer() { + GameBoardMember member = GameFixture.member(7L, board, "선택 불가", Level.A); + given(gameBoardReader.readForUpdate(BOARD_ID)).willReturn(board); + given(gameBoardMemberRepository.findByGameBoardIdAndIdIn(BOARD_ID, List.of(7L))) + .willReturn(List.of(member)); + given(availabilityPolicy.hasBlockedMember(any(), any(), any())).willReturn(true); + + assertThatThrownBy(() -> gameCommandService.createGame( + MEMBER_ID, new GameCreateCommand(BOARD_ID, List.of(7L)))) + .isInstanceOfSatisfying(GameException.class, exception -> + assertThat(exception.getCode()) + .isEqualTo(GameErrorCode.UNAVAILABLE_GAME_PLAYER)); + + then(gameRepository).should(never()).save(any()); + then(eventPublisher).should(never()).publishEvent(any()); + } + + @Test + @DisplayName("급수없음 선수도 수동 선택에서는 게임에 추가할 수 있다") + void createGame_allowsPlayerWithoutLevelWhenManuallySelected() { + GameBoardMember member = GameFixture.member(7L, board, "급수없음", Level.NONE); + given(gameBoardReader.readForUpdate(BOARD_ID)).willReturn(board); + given(gameBoardMemberRepository.findByGameBoardIdAndIdIn(BOARD_ID, List.of(7L))) + .willReturn(List.of(member)); + given(gameRepository.save(any(Game.class))).willAnswer(invocation -> invocation.getArgument(0)); + + gameCommandService.createGame( + MEMBER_ID, new GameCreateCommand(BOARD_ID, List.of(7L))); + + then(gameRepository).should().save(any(Game.class)); + } + @Test @DisplayName("인원이 없거나 4명을 초과하면 INVALID_GAME_PLAYER_COUNT 예외 (command 검증)") void createGame_invalidPlayerCount() { From 2e40058b636aef3a3eb7f3fa27a231dbde2184ab Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Mon, 24 Aug 2026 22:32:30 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=EB=9E=9C=EB=8D=A4=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=ED=83=80=EC=9E=85=20=EC=84=A0=ED=83=9D=EA=B8=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/domain/game/enums/GameMatchType.java | 7 ++ .../domain/game/exception/GameErrorCode.java | 1 + .../selector/GameMatchTypeSelector.java | 42 ++++++++++ .../selector/GameMatchTypeSelectorTest.java | 80 +++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 src/main/java/umc/cockple/demo/domain/game/enums/GameMatchType.java create mode 100644 src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelector.java create mode 100644 src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelectorTest.java diff --git a/src/main/java/umc/cockple/demo/domain/game/enums/GameMatchType.java b/src/main/java/umc/cockple/demo/domain/game/enums/GameMatchType.java new file mode 100644 index 000000000..1d5b0aa03 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/enums/GameMatchType.java @@ -0,0 +1,7 @@ +package umc.cockple.demo.domain.game.enums; + +public enum GameMatchType { + MIXED_DOUBLES, + MEN_DOUBLES, + WOMEN_DOUBLES +} diff --git a/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java b/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java index e7abf4302..14c41dfa2 100644 --- a/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java +++ b/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java @@ -33,6 +33,7 @@ public enum GameErrorCode implements BaseErrorCode { ACTIVE_GAME_MEMBER_CANNOT_BE_INACTIVE(HttpStatus.BAD_REQUEST, "GAME413", "진행 또는 대기 중인 게임에 포함된 선수는 참여 해제할 수 없습니다."), INACTIVE_GAME_PLAYER(HttpStatus.BAD_REQUEST, "GAME414", "불참 상태의 선수는 게임에 추가할 수 없습니다."), INVALID_COURT_COUNT(HttpStatus.BAD_REQUEST, "GAME415", "코트는 최소 1개, 최대 10개까지 관리할 수 있습니다."), + INSUFFICIENT_GENDER_COMPOSITION(HttpStatus.BAD_REQUEST, "GAME416", "매칭 가능한 성별 구성이 부족합니다."), UNAVAILABLE_GAME_PLAYER(HttpStatus.BAD_REQUEST, "GAME418", "현재 선택할 수 없는 선수가 포함되어 있습니다.") ; diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelector.java b/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelector.java new file mode 100644 index 000000000..4b67b1fae --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelector.java @@ -0,0 +1,42 @@ +package umc.cockple.demo.domain.game.service.support.selector; + +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.enums.GameMatchType; +import umc.cockple.demo.domain.game.exception.GameErrorCode; +import umc.cockple.demo.domain.game.exception.GameException; +import umc.cockple.demo.global.enums.Gender; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +@Component +public class GameMatchTypeSelector { + + public GameMatchType select(List candidates) { + long maleCount = countByGender(candidates, Gender.MALE); + long femaleCount = countByGender(candidates, Gender.FEMALE); + + List availableTypes = new ArrayList<>(); + if (maleCount >= 2 && femaleCount >= 2) { + availableTypes.add(GameMatchType.MIXED_DOUBLES); + } + if (maleCount >= 4) { + availableTypes.add(GameMatchType.MEN_DOUBLES); + } + if (femaleCount >= 4) { + availableTypes.add(GameMatchType.WOMEN_DOUBLES); + } + if (availableTypes.isEmpty()) { + throw new GameException(GameErrorCode.INSUFFICIENT_GENDER_COMPOSITION); + } + + int selectedIndex = ThreadLocalRandom.current().nextInt(availableTypes.size()); + return availableTypes.get(selectedIndex); + } + + private long countByGender(List candidates, Gender gender) { + return candidates.stream().filter(candidate -> candidate.getGender() == gender).count(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelectorTest.java b/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelectorTest.java new file mode 100644 index 000000000..86ec00cae --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelectorTest.java @@ -0,0 +1,80 @@ +package umc.cockple.demo.domain.game.service.support.selector; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import umc.cockple.demo.domain.game.domain.GameBoard; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.enums.GameMatchType; +import umc.cockple.demo.domain.game.exception.GameErrorCode; +import umc.cockple.demo.domain.game.exception.GameException; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.GameFixture; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("GameMatchTypeSelector") +class GameMatchTypeSelectorTest { + + private final GameMatchTypeSelector selector = new GameMatchTypeSelector(); + private final GameBoard board = GameFixture.gameBoard(1L); + + @Test + @DisplayName("남자 2명과 여자 2명이면 혼복만 선택 가능하다") + void select_returnsMixedDoubles() { + List candidates = List.of( + member(1L, Gender.MALE), member(2L, Gender.MALE), + member(3L, Gender.FEMALE), member(4L, Gender.FEMALE)); + + assertThat(selector.select(candidates)).isEqualTo(GameMatchType.MIXED_DOUBLES); + } + + @Test + @DisplayName("남자 4명이면 남복만 선택 가능하다") + void select_returnsMenDoubles() { + List candidates = List.of( + member(1L, Gender.MALE), member(2L, Gender.MALE), + member(3L, Gender.MALE), member(4L, Gender.MALE)); + + assertThat(selector.select(candidates)).isEqualTo(GameMatchType.MEN_DOUBLES); + } + + @Test + @DisplayName("여자 4명이면 여복만 선택 가능하다") + void select_returnsWomenDoubles() { + List candidates = List.of( + member(1L, Gender.FEMALE), member(2L, Gender.FEMALE), + member(3L, Gender.FEMALE), member(4L, Gender.FEMALE)); + + assertThat(selector.select(candidates)).isEqualTo(GameMatchType.WOMEN_DOUBLES); + } + + @Test + @DisplayName("가능한 성별 구성이 없으면 GAME416 예외를 던진다") + void select_rejectsInsufficientGenderComposition() { + List candidates = List.of( + member(1L, Gender.MALE), member(2L, Gender.MALE), + member(3L, Gender.MALE), member(4L, Gender.FEMALE)); + + assertThatThrownBy(() -> selector.select(candidates)) + .isInstanceOfSatisfying(GameException.class, exception -> + assertThat(exception.getCode()) + .isEqualTo(GameErrorCode.INSUFFICIENT_GENDER_COMPOSITION)); + } + + private GameBoardMember member(Long id, Gender gender) { + return GameBoardMember.builder() + .id(id) + .gameBoard(board) + .name("선수" + id) + .gender(gender) + .level(Level.A) + .participating(true) + .gameCount(0) + .shuttlecockSubmitted(false) + .build(); + } +} From f56757cd4195f82aa96556346ec5b7c8f8c3a0b1 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Mon, 24 Aug 2026 22:36:03 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20=EB=9E=9C=EB=8D=A4=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=EB=8A=A5=EB=A0=A5=EC=B9=98=20=EC=A0=90=EC=88=98=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GameAbilityScoreCalculator.java | 46 +++++++++ .../GameAbilityScoreCalculatorTest.java | 96 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculator.java create mode 100644 src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculatorTest.java diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculator.java b/src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculator.java new file mode 100644 index 000000000..92a81960e --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculator.java @@ -0,0 +1,46 @@ +package umc.cockple.demo.domain.game.service.support.calculator; + +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.enums.AgeGroup; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; + +@Component +public class GameAbilityScoreCalculator { + + public int calculate(GameBoardMember member) { + int baseScore = baseScore(member.getLevel()); + if (member.getGender() == Gender.FEMALE) { + return baseScore - 10; + } + return baseScore + maleAgeBonus(member.getAgeGroup()); + } + + private int baseScore(Level level) { + return switch (level) { + case EXPERT -> 100; + case SEMI_EXPERT -> 90; + case A -> 80; + case B -> 70; + case C -> 60; + case D -> 50; + case BEGINNER -> 40; + case NOVICE -> 30; + case NONE -> throw new IllegalArgumentException( + "급수없음 선수는 능력치 점수 계산 대상이 아닙니다."); + }; + } + + private int maleAgeBonus(AgeGroup ageGroup) { + if (ageGroup == null) { + return 4; + } + return switch (ageGroup) { + case TEENS, TWENTIES -> 6; + case THIRTIES -> 4; + case FORTIES -> 2; + case FIFTIES, SIXTIES, SEVENTIES -> 0; + }; + } +} diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculatorTest.java b/src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculatorTest.java new file mode 100644 index 000000000..767da027a --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculatorTest.java @@ -0,0 +1,96 @@ +package umc.cockple.demo.domain.game.service.support.calculator; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.enums.AgeGroup; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("GameAbilityScoreCalculator") +class GameAbilityScoreCalculatorTest { + + private final GameAbilityScoreCalculator calculator = new GameAbilityScoreCalculator(); + + @ParameterizedTest(name = "{0} 기본 점수는 {1}점이다") + @MethodSource("validLevelScores") + @DisplayName("전체 유효 급수의 기본 점수를 10배 정수로 계산한다") + void calculate_appliesLevelScore(Level level, int expectedScore) { + GameBoardMember member = member(Gender.MALE, level, AgeGroup.FIFTIES); + + assertThat(calculator.calculate(member)).isEqualTo(expectedScore); + } + + @ParameterizedTest(name = "남성 {0}의 나이 보정은 {1}점이다") + @CsvSource({ + "TEENS, 6", + "TWENTIES, 6", + "THIRTIES, 4", + "FORTIES, 2", + "FIFTIES, 0", + "SIXTIES, 0", + "SEVENTIES, 0" + }) + @DisplayName("남성 연령대별 보정을 적용한다") + void calculate_appliesMaleAgeBonus(AgeGroup ageGroup, int expectedBonus) { + GameBoardMember member = member(Gender.MALE, Level.A, ageGroup); + + assertThat(calculator.calculate(member)).isEqualTo(80 + expectedBonus); + } + + @Test + @DisplayName("연령대가 없는 남성은 30대와 동일한 보정을 적용한다") + void calculate_appliesDefaultMaleAgeBonus() { + GameBoardMember member = member(Gender.MALE, Level.A, null); + + assertThat(calculator.calculate(member)).isEqualTo(84); + } + + @ParameterizedTest(name = "여성 {0}도 나이와 관계없이 10점 감점한다") + @EnumSource(AgeGroup.class) + @DisplayName("여성은 연령대와 관계없이 기본 점수에서 10점을 감점한다") + void calculate_appliesFemalePenalty(AgeGroup ageGroup) { + GameBoardMember member = member(Gender.FEMALE, Level.A, ageGroup); + + assertThat(calculator.calculate(member)).isEqualTo(70); + } + + @Test + @DisplayName("급수없음 선수에게는 능력치 점수를 부여하지 않는다") + void calculate_rejectsMemberWithoutLevel() { + GameBoardMember member = member(Gender.MALE, Level.NONE, AgeGroup.TWENTIES); + + assertThatThrownBy(() -> calculator.calculate(member)) + .isInstanceOf(IllegalArgumentException.class); + } + + private static Stream validLevelScores() { + return Stream.of( + Arguments.of(Level.EXPERT, 100), + Arguments.of(Level.SEMI_EXPERT, 90), + Arguments.of(Level.A, 80), + Arguments.of(Level.B, 70), + Arguments.of(Level.C, 60), + Arguments.of(Level.D, 50), + Arguments.of(Level.BEGINNER, 40), + Arguments.of(Level.NOVICE, 30)); + } + + private GameBoardMember member(Gender gender, Level level, AgeGroup ageGroup) { + return GameBoardMember.builder() + .gender(gender) + .level(level) + .ageGroup(ageGroup) + .build(); + } +} From a32a7d1de04017df7a9bbaca4739b49b1ccc8a5e Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Mon, 24 Aug 2026 22:39:24 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=EB=9E=9C=EB=8D=A4=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=ED=9B=84=EB=B3=B4=EA=B5=B0=20=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/game/exception/GameErrorCode.java | 1 + .../selector/GameCandidatePoolSelector.java | 118 ++++++++++++++ .../GameCandidatePoolSelectorTest.java | 154 ++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelector.java create mode 100644 src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelectorTest.java diff --git a/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java b/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java index 14c41dfa2..7840406eb 100644 --- a/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java +++ b/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java @@ -34,6 +34,7 @@ public enum GameErrorCode implements BaseErrorCode { INACTIVE_GAME_PLAYER(HttpStatus.BAD_REQUEST, "GAME414", "불참 상태의 선수는 게임에 추가할 수 없습니다."), INVALID_COURT_COUNT(HttpStatus.BAD_REQUEST, "GAME415", "코트는 최소 1개, 최대 10개까지 관리할 수 있습니다."), INSUFFICIENT_GENDER_COMPOSITION(HttpStatus.BAD_REQUEST, "GAME416", "매칭 가능한 성별 구성이 부족합니다."), + RANDOM_MATCH_NOT_FOUND(HttpStatus.BAD_REQUEST, "GAME417", "매칭할 조합을 찾지 못했습니다."), UNAVAILABLE_GAME_PLAYER(HttpStatus.BAD_REQUEST, "GAME418", "현재 선택할 수 없는 선수가 포함되어 있습니다.") ; diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelector.java b/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelector.java new file mode 100644 index 000000000..d8d1ca04d --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelector.java @@ -0,0 +1,118 @@ +package umc.cockple.demo.domain.game.service.support.selector; + +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.enums.GameMatchType; +import umc.cockple.demo.domain.game.exception.GameErrorCode; +import umc.cockple.demo.domain.game.exception.GameException; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component +public class GameCandidatePoolSelector { + + private static final int MAX_GAME_COUNT_GAP = 5; + private static final int MAX_CANDIDATE_POOL_SIZE = 12; + private static final int REQUIRED_PLAYER_COUNT = 4; + + private static final Comparator FAIRNESS_ORDER = Comparator + .comparingInt(GameBoardMember::getGameCount) + .thenComparing(GameBoardMember::getId); + + public List select( + List availableMembers, + GameMatchType matchType) { + List sortedCandidates = availableMembers.stream() + .filter(member -> member.getLevel() != Level.NONE) + .filter(member -> belongsToMatchType(member, matchType)) + .sorted(FAIRNESS_ORDER) + .toList(); + if (sortedCandidates.isEmpty()) { + throw randomMatchNotFound(); + } + + int minimumGameCount = sortedCandidates.get(0).getGameCount(); + for (int gap = 0; gap <= MAX_GAME_COUNT_GAP; gap++) { + int maximumGameCount = minimumGameCount + gap; + List expandedPool = sortedCandidates.stream() + .filter(member -> member.getGameCount() <= maximumGameCount) + .toList(); + if (hasRequiredComposition(expandedPool, matchType)) { + return limitSize(expandedPool, matchType); + } + } + throw randomMatchNotFound(); + } + + private boolean belongsToMatchType(GameBoardMember member, GameMatchType matchType) { + return switch (matchType) { + case MIXED_DOUBLES -> true; + case MEN_DOUBLES -> member.getGender() == Gender.MALE; + case WOMEN_DOUBLES -> member.getGender() == Gender.FEMALE; + }; + } + + private boolean hasRequiredComposition( + List candidates, + GameMatchType matchType) { + if (matchType == GameMatchType.MIXED_DOUBLES) { + return countByGender(candidates, Gender.MALE) >= 2 + && countByGender(candidates, Gender.FEMALE) >= 2; + } + return candidates.size() >= REQUIRED_PLAYER_COUNT; + } + + private List limitSize( + List candidates, + GameMatchType matchType) { + if (candidates.size() <= MAX_CANDIDATE_POOL_SIZE) { + return candidates; + } + if (matchType != GameMatchType.MIXED_DOUBLES) { + return candidates.subList(0, MAX_CANDIDATE_POOL_SIZE); + } + + List selected = new ArrayList<>(); + addLowestGameCountMembers(selected, candidates, Gender.MALE, 2); + addLowestGameCountMembers(selected, candidates, Gender.FEMALE, 2); + + Set selectedIds = new HashSet<>(); + selected.forEach(member -> selectedIds.add(member.getId())); + for (GameBoardMember candidate : candidates) { + if (selected.size() == MAX_CANDIDATE_POOL_SIZE) { + break; + } + if (selectedIds.add(candidate.getId())) { + selected.add(candidate); + } + } + return selected.stream().sorted(FAIRNESS_ORDER).toList(); + } + + private void addLowestGameCountMembers( + List selected, + List candidates, + Gender gender, + int count) { + candidates.stream() + .filter(candidate -> candidate.getGender() == gender) + .limit(count) + .forEach(selected::add); + } + + private long countByGender(List members, Gender gender) { + return members.stream() + .filter(member -> member.getGender() == gender) + .count(); + } + + private GameException randomMatchNotFound() { + return new GameException(GameErrorCode.RANDOM_MATCH_NOT_FOUND); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelectorTest.java b/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelectorTest.java new file mode 100644 index 000000000..c67bd7678 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelectorTest.java @@ -0,0 +1,154 @@ +package umc.cockple.demo.domain.game.service.support.selector; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.enums.GameMatchType; +import umc.cockple.demo.domain.game.exception.GameErrorCode; +import umc.cockple.demo.domain.game.exception.GameException; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("GameCandidatePoolSelector") +class GameCandidatePoolSelectorTest { + + private final GameCandidatePoolSelector selector = new GameCandidatePoolSelector(); + + @Test + @DisplayName("급수없음 선수는 경기 수가 가장 적어도 후보 풀에서 제외한다") + void select_excludesMembersWithoutLevel() { + List members = List.of( + member(1L, Gender.MALE, Level.NONE, 0), + member(2L, Gender.MALE, Level.A, 1), + member(3L, Gender.MALE, Level.A, 1), + member(4L, Gender.MALE, Level.A, 1), + member(5L, Gender.MALE, Level.A, 1)); + + List result = selector.select(members, GameMatchType.MEN_DOUBLES); + + assertThat(ids(result)).containsExactly(2L, 3L, 4L, 5L); + } + + @Test + @DisplayName("필요한 구성이 완성된 최초 경기 수 단계에서 확장을 멈춘다") + void select_stopsAtFirstSufficientGameCountThreshold() { + List members = List.of( + member(5L, Gender.MALE, Level.A, 1), + member(4L, Gender.MALE, Level.A, 0), + member(2L, Gender.MALE, Level.A, 0), + member(1L, Gender.MALE, Level.A, 0), + member(3L, Gender.MALE, Level.A, 0)); + + List result = selector.select(members, GameMatchType.MEN_DOUBLES); + + assertThat(ids(result)).containsExactly(1L, 2L, 3L, 4L); + } + + @Test + @DisplayName("최소 경기 수에서 정확히 5경기 차이인 선수까지 확장한다") + void select_includesExactMaximumGameCountGap() { + List members = List.of( + member(1L, Gender.FEMALE, Level.A, 0), + member(2L, Gender.FEMALE, Level.A, 5), + member(3L, Gender.FEMALE, Level.A, 5), + member(4L, Gender.FEMALE, Level.A, 5)); + + List result = selector.select(members, GameMatchType.WOMEN_DOUBLES); + + assertThat(ids(result)).containsExactly(1L, 2L, 3L, 4L); + } + + @Test + @DisplayName("최소 경기 수 +5 안에 필요한 구성이 없으면 GAME417 예외를 던진다") + void select_rejectsWhenCandidateExpansionFails() { + List members = List.of( + member(1L, Gender.MALE, Level.A, 0), + member(2L, Gender.MALE, Level.A, 1), + member(3L, Gender.MALE, Level.A, 5), + member(4L, Gender.MALE, Level.A, 6)); + + assertThatThrownBy(() -> selector.select(members, GameMatchType.MEN_DOUBLES)) + .isInstanceOfSatisfying(GameException.class, exception -> + assertThat(exception.getCode()) + .isEqualTo(GameErrorCode.RANDOM_MATCH_NOT_FOUND)); + } + + @Test + @DisplayName("혼복도 최소 경기 수 +5 안에 남녀 각 2명이 없으면 GAME417 예외를 던진다") + void select_rejectsInsufficientMixedCompositionWithinGameCountGap() { + List members = List.of( + member(1L, Gender.MALE, Level.A, 0), + member(2L, Gender.MALE, Level.A, 0), + member(3L, Gender.FEMALE, Level.A, 0), + member(4L, Gender.FEMALE, Level.A, 6)); + + assertThatThrownBy(() -> selector.select(members, GameMatchType.MIXED_DOUBLES)) + .isInstanceOfSatisfying(GameException.class, exception -> + assertThat(exception.getCode()) + .isEqualTo(GameErrorCode.RANDOM_MATCH_NOT_FOUND)); + } + + @Test + @DisplayName("선택된 동일 성별 타입과 다른 선수는 후보 풀에서 제외한다") + void select_excludesOtherGenderForSameGenderDoubles() { + List members = List.of( + member(1L, Gender.FEMALE, Level.A, 0), + member(2L, Gender.MALE, Level.A, 0), + member(3L, Gender.MALE, Level.A, 0), + member(4L, Gender.MALE, Level.A, 0), + member(5L, Gender.MALE, Level.A, 0)); + + List result = selector.select(members, GameMatchType.MEN_DOUBLES); + + assertThat(ids(result)).containsExactly(2L, 3L, 4L, 5L); + } + + @Test + @DisplayName("동일 경기 수의 단일 성별 후보 풀은 ID 순으로 최대 12명까지 선택한다") + void select_limitsSameGenderPoolToTwelveById() { + List members = new ArrayList<>(); + for (long id = 13L; id >= 1L; id--) { + members.add(member(id, Gender.MALE, Level.A, 0)); + } + + List result = selector.select(members, GameMatchType.MEN_DOUBLES); + + assertThat(ids(result)).containsExactly( + 1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L, 11L, 12L); + } + + @Test + @DisplayName("혼복 12명 제한에서도 남녀 각 2명을 보장한다") + void select_preservesGenderCompositionAtMixedPoolLimit() { + List members = new ArrayList<>(); + for (long id = 1L; id <= 11L; id++) { + members.add(member(id, Gender.MALE, Level.A, 0)); + } + members.add(member(12L, Gender.FEMALE, Level.A, 0)); + members.add(member(13L, Gender.FEMALE, Level.A, 0)); + + List result = selector.select(members, GameMatchType.MIXED_DOUBLES); + + assertThat(result).hasSize(12); + assertThat(ids(result)).contains(12L, 13L).doesNotContain(11L); + } + + private List ids(List members) { + return members.stream().map(GameBoardMember::getId).toList(); + } + + private GameBoardMember member(Long id, Gender gender, Level level, int gameCount) { + return GameBoardMember.builder() + .id(id) + .gender(gender) + .level(level) + .gameCount(gameCount) + .build(); + } +} From 42aa2d430b9298aa057070645eb060000cc6f471 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Mon, 24 Aug 2026 22:45:52 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20=EB=9E=9C=EB=8D=A4=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=EC=B5=9C=EC=A0=81=20=EC=A1=B0=ED=95=A9=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../selector/GameBestMatchSelector.java | 174 +++++++++++++++ .../selector/GameBestMatchSelectorTest.java | 200 ++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelector.java create mode 100644 src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelectorTest.java diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelector.java b/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelector.java new file mode 100644 index 000000000..321b08963 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelector.java @@ -0,0 +1,174 @@ +package umc.cockple.demo.domain.game.service.support.selector; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.enums.GameMatchType; +import umc.cockple.demo.domain.game.exception.GameErrorCode; +import umc.cockple.demo.domain.game.exception.GameException; +import umc.cockple.demo.domain.game.service.support.calculator.GameAbilityScoreCalculator; +import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator.GamePairHistory; +import umc.cockple.demo.global.enums.Gender; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class GameBestMatchSelector { + + private static final int REQUIRED_PLAYER_COUNT = 4; + private static final int BALANCE_WEIGHT = 10; + private static final int DUPLICATE_WEIGHT = 15; + + private final GameAbilityScoreCalculator abilityScoreCalculator; + + public List select( + List candidatePool, + GameMatchType matchType, + GamePairHistory pairHistory) { + MatchScore bestMatch = null; + int size = candidatePool.size(); + for (int first = 0; first < size - 3; first++) { + for (int second = first + 1; second < size - 2; second++) { + for (int third = second + 1; third < size - 1; third++) { + for (int fourth = third + 1; fourth < size; fourth++) { + List members = List.of( + candidatePool.get(first), + candidatePool.get(second), + candidatePool.get(third), + candidatePool.get(fourth)); + if (!matchesType(members, matchType)) { + continue; + } + + MatchScore score = score(members, matchType, pairHistory); + if (bestMatch == null || score.compareTo(bestMatch) < 0) { + bestMatch = score; + } + } + } + } + } + if (bestMatch == null) { + throw new GameException(GameErrorCode.RANDOM_MATCH_NOT_FOUND); + } + return bestMatch.memberIds(); + } + + private boolean matchesType(List members, GameMatchType matchType) { + if (members.size() != REQUIRED_PLAYER_COUNT) { + return false; + } + long maleCount = countByGender(members, Gender.MALE); + long femaleCount = countByGender(members, Gender.FEMALE); + return switch (matchType) { + case MIXED_DOUBLES -> maleCount == 2 && femaleCount == 2; + case MEN_DOUBLES -> maleCount == 4; + case WOMEN_DOUBLES -> femaleCount == 4; + }; + } + + private MatchScore score( + List members, + GameMatchType matchType, + GamePairHistory pairHistory) { + int balanceScore = minimumTeamDifference(members, matchType) * BALANCE_WEIGHT; + int duplicateScore = duplicateScore(members, pairHistory); + int fairnessScore = members.stream() + .mapToInt(GameBoardMember::getGameCount) + .sum(); + int totalScore = balanceScore + duplicateScore * DUPLICATE_WEIGHT + fairnessScore; + List memberIds = members.stream() + .map(GameBoardMember::getId) + .sorted() + .toList(); + return new MatchScore(totalScore, duplicateScore, fairnessScore, memberIds); + } + + private int minimumTeamDifference( + List members, + GameMatchType matchType) { + if (matchType == GameMatchType.MIXED_DOUBLES) { + List males = byGender(members, Gender.MALE); + List females = byGender(members, Gender.FEMALE); + return Math.min( + teamDifference(males.get(0), females.get(0), males.get(1), females.get(1)), + teamDifference(males.get(0), females.get(1), males.get(1), females.get(0))); + } + return Math.min( + teamDifference(members.get(0), members.get(1), members.get(2), members.get(3)), + Math.min( + teamDifference(members.get(0), members.get(2), members.get(1), members.get(3)), + teamDifference(members.get(0), members.get(3), members.get(1), members.get(2)))); + } + + private int teamDifference( + GameBoardMember firstA, + GameBoardMember firstB, + GameBoardMember secondA, + GameBoardMember secondB) { + int firstTeamScore = abilityScoreCalculator.calculate(firstA) + + abilityScoreCalculator.calculate(firstB); + int secondTeamScore = abilityScoreCalculator.calculate(secondA) + + abilityScoreCalculator.calculate(secondB); + return Math.abs(firstTeamScore - secondTeamScore); + } + + private int duplicateScore( + List members, + GamePairHistory pairHistory) { + int score = 0; + for (int first = 0; first < members.size(); first++) { + for (int second = first + 1; second < members.size(); second++) { + score += pairHistory.count( + members.get(first).getId(), + members.get(second).getId()); + } + } + return score; + } + + private List byGender( + List members, + Gender gender) { + return members.stream() + .filter(member -> member.getGender() == gender) + .toList(); + } + + private long countByGender(List members, Gender gender) { + return members.stream() + .filter(member -> member.getGender() == gender) + .count(); + } + + private record MatchScore( + int totalScore, + int duplicateScore, + int fairnessScore, + List memberIds) implements Comparable { + + @Override + public int compareTo(MatchScore other) { + int compared = Integer.compare(totalScore, other.totalScore); + if (compared != 0) { + return compared; + } + compared = Integer.compare(duplicateScore, other.duplicateScore); + if (compared != 0) { + return compared; + } + compared = Integer.compare(fairnessScore, other.fairnessScore); + if (compared != 0) { + return compared; + } + for (int index = 0; index < memberIds.size(); index++) { + compared = memberIds.get(index).compareTo(other.memberIds.get(index)); + if (compared != 0) { + return compared; + } + } + return 0; + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelectorTest.java b/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelectorTest.java new file mode 100644 index 000000000..04a603d74 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelectorTest.java @@ -0,0 +1,200 @@ +package umc.cockple.demo.domain.game.service.support.selector; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import umc.cockple.demo.domain.game.domain.Game; +import umc.cockple.demo.domain.game.domain.GameBoard; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.domain.GamePlayer; +import umc.cockple.demo.domain.game.enums.AgeGroup; +import umc.cockple.demo.domain.game.enums.GameMatchType; +import umc.cockple.demo.domain.game.exception.GameErrorCode; +import umc.cockple.demo.domain.game.exception.GameException; +import umc.cockple.demo.domain.game.service.support.calculator.GameAbilityScoreCalculator; +import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator; +import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator.GamePairHistory; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.GameFixture; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("GameBestMatchSelector") +class GameBestMatchSelectorTest { + + private final GamePairHistoryCalculator pairHistoryCalculator = + new GamePairHistoryCalculator(); + private final GameBestMatchSelector selector = + new GameBestMatchSelector(new GameAbilityScoreCalculator()); + private final GamePairHistory emptyHistory = pairHistoryCalculator.calculate(List.of()); + private final GameBoard board = GameFixture.gameBoard(1L); + + @Test + @DisplayName("동일 성별 복식은 세 가지 팀 분할 중 점수 차이가 가장 작은 조합을 사용한다") + void select_comparesAllSameGenderTeamSplits() { + List candidates = List.of( + member(1L, Gender.MALE, Level.EXPERT, AgeGroup.FIFTIES, 0), + member(2L, Gender.MALE, Level.SEMI_EXPERT, AgeGroup.FIFTIES, 0), + member(3L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(4L, Gender.MALE, Level.NOVICE, AgeGroup.FIFTIES, 0), + member(5L, Gender.MALE, Level.NOVICE, AgeGroup.FIFTIES, 0)); + + List result = selector.select( + candidates, GameMatchType.MEN_DOUBLES, emptyHistory); + + assertThat(result).containsExactly(1L, 2L, 4L, 5L); + } + + @Test + @DisplayName("혼복은 각 팀이 남녀 한 명씩인 두 가지 분할만 비교한다") + void select_comparesOnlyMixedGenderTeamSplits() { + List candidates = List.of( + member(1L, Gender.MALE, Level.EXPERT, AgeGroup.FIFTIES, 0), + member(2L, Gender.MALE, Level.C, AgeGroup.FIFTIES, 0), + member(3L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(4L, Gender.FEMALE, Level.EXPERT, AgeGroup.FIFTIES, 0), + member(5L, Gender.FEMALE, Level.A, AgeGroup.FIFTIES, 0)); + + List result = selector.select( + candidates, GameMatchType.MIXED_DOUBLES, emptyHistory); + + assertThat(result).containsExactly(1L, 3L, 4L, 5L); + } + + @Test + @DisplayName("밸런스 점수와 중복 점수에 각각 10과 15의 가중치를 적용한다") + void select_appliesBalanceAndDuplicateWeights() { + List candidates = weightedScoreCandidates(0, 0, 0, 0, 0); + GamePairHistory pairHistory = historyOf(candidates.get(1), candidates.get(2)); + + List result = selector.select( + candidates, GameMatchType.MEN_DOUBLES, pairHistory); + + assertThat(result).containsExactly(1L, 2L, 3L, 4L); + } + + @Test + @DisplayName("4명 사이 여섯 pair의 완료 경기 중복 횟수를 모두 합산한다") + void select_sumsAllPairHistoryCounts() { + List candidates = List.of( + member(1L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(2L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(3L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(4L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(5L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0)); + GamePairHistory pairHistory = historyOf( + candidates.get(0), candidates.get(1), candidates.get(2), candidates.get(3)); + + List result = selector.select( + candidates, GameMatchType.MEN_DOUBLES, pairHistory); + + assertThat(result).containsExactly(1L, 2L, 3L, 5L); + } + + @Test + @DisplayName("최종 점수가 같으면 중복 점수가 낮은 조합을 우선한다") + void select_usesDuplicateScoreAsFirstTieBreaker() { + List candidates = List.of( + member(1L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(2L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(3L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(4L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(5L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 15)); + GamePairHistory pairHistory = historyOf(candidates.get(1), candidates.get(2)); + + List result = selector.select( + candidates, GameMatchType.MEN_DOUBLES, pairHistory); + + assertThat(result).containsExactly(1L, 2L, 4L, 5L); + } + + @Test + @DisplayName("최종 점수와 중복 점수가 같으면 경기 수 합이 낮은 조합을 우선한다") + void select_usesFairnessScoreAsSecondTieBreaker() { + List candidates = weightedScoreCandidates(0, 20, 0, 0, 0); + + List result = selector.select( + candidates, GameMatchType.MEN_DOUBLES, emptyHistory); + + assertThat(result).containsExactly(1L, 3L, 4L, 5L); + } + + @Test + @DisplayName("모든 점수가 같으면 정렬된 명단 ID가 빠른 조합을 우선한다") + void select_usesMemberIdsAsFinalTieBreaker() { + List candidates = List.of( + member(5L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(2L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(4L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(1L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(3L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0)); + + List result = selector.select( + candidates, GameMatchType.MEN_DOUBLES, emptyHistory); + + assertThat(result).containsExactly(1L, 2L, 3L, 4L); + } + + @Test + @DisplayName("매치 타입에 맞는 4인 조합이 없으면 GAME417 예외를 던진다") + void select_rejectsWhenNoCombinationMatchesType() { + List candidates = List.of( + member(1L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(2L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(3L, Gender.MALE, Level.A, AgeGroup.FIFTIES, 0), + member(4L, Gender.FEMALE, Level.A, AgeGroup.FIFTIES, 0)); + + assertThatThrownBy(() -> selector.select( + candidates, GameMatchType.MIXED_DOUBLES, emptyHistory)) + .isInstanceOfSatisfying(GameException.class, exception -> + assertThat(exception.getCode()) + .isEqualTo(GameErrorCode.RANDOM_MATCH_NOT_FOUND)); + } + + private List weightedScoreCandidates( + int firstGameCount, + int secondGameCount, + int thirdGameCount, + int fourthGameCount, + int fifthGameCount) { + return List.of( + member(1L, Gender.MALE, Level.NOVICE, AgeGroup.FIFTIES, firstGameCount), + member(2L, Gender.MALE, Level.NOVICE, AgeGroup.FORTIES, secondGameCount), + member(3L, Gender.MALE, Level.NOVICE, AgeGroup.THIRTIES, thirdGameCount), + member(4L, Gender.MALE, Level.NOVICE, AgeGroup.TWENTIES, fourthGameCount), + member(5L, Gender.MALE, Level.BEGINNER, AgeGroup.FORTIES, fifthGameCount)); + } + + private GamePairHistory historyOf(GameBoardMember... members) { + GamePlayer[] players = IntStream.range(0, members.length) + .mapToObj(index -> GameFixture.player(members[index], index)) + .toArray(GamePlayer[]::new); + Game completedGame = GameFixture.completedGame( + 1L, board, LocalDateTime.of(2026, 8, 24, 10, 0), players); + return pairHistoryCalculator.calculate(List.of(completedGame)); + } + + private GameBoardMember member( + Long id, + Gender gender, + Level level, + AgeGroup ageGroup, + int gameCount) { + return GameBoardMember.builder() + .id(id) + .gameBoard(board) + .name("선수" + id) + .gender(gender) + .level(level) + .ageGroup(ageGroup) + .participating(true) + .gameCount(gameCount) + .shuttlecockSubmitted(false) + .build(); + } +} From 3f83e723849026063792a1aa657b967d4b971c2e Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Mon, 24 Aug 2026 22:50:13 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20=EB=9E=9C=EB=8D=A4=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=EC=98=A4=EC=BC=80=EC=8A=A4=ED=8A=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/game/exception/GameErrorCode.java | 3 +- .../repository/GameBoardMemberRepository.java | 2 + .../query/GameRandomMatchQueryService.java | 75 ++++++++ .../query/result/GameRandomMatchResult.java | 6 + .../GameRandomMatchQueryServiceTest.java | 174 ++++++++++++++++++ 5 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java create mode 100644 src/main/java/umc/cockple/demo/domain/game/service/query/result/GameRandomMatchResult.java create mode 100644 src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java diff --git a/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java b/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java index 7840406eb..009dd367d 100644 --- a/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java +++ b/src/main/java/umc/cockple/demo/domain/game/exception/GameErrorCode.java @@ -35,7 +35,8 @@ public enum GameErrorCode implements BaseErrorCode { INVALID_COURT_COUNT(HttpStatus.BAD_REQUEST, "GAME415", "코트는 최소 1개, 최대 10개까지 관리할 수 있습니다."), INSUFFICIENT_GENDER_COMPOSITION(HttpStatus.BAD_REQUEST, "GAME416", "매칭 가능한 성별 구성이 부족합니다."), RANDOM_MATCH_NOT_FOUND(HttpStatus.BAD_REQUEST, "GAME417", "매칭할 조합을 찾지 못했습니다."), - UNAVAILABLE_GAME_PLAYER(HttpStatus.BAD_REQUEST, "GAME418", "현재 선택할 수 없는 선수가 포함되어 있습니다.") + UNAVAILABLE_GAME_PLAYER(HttpStatus.BAD_REQUEST, "GAME418", "현재 선택할 수 없는 선수가 포함되어 있습니다."), + INSUFFICIENT_AVAILABLE_PLAYERS(HttpStatus.BAD_REQUEST, "GAME419", "대기 중인 인원이 부족합니다.") ; diff --git a/src/main/java/umc/cockple/demo/domain/game/repository/GameBoardMemberRepository.java b/src/main/java/umc/cockple/demo/domain/game/repository/GameBoardMemberRepository.java index 7d35dfb0e..9c6d80d55 100644 --- a/src/main/java/umc/cockple/demo/domain/game/repository/GameBoardMemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/game/repository/GameBoardMemberRepository.java @@ -18,6 +18,8 @@ public interface GameBoardMemberRepository extends JpaRepository findByGameBoardIdAndIdIn(Long gameBoardId, Collection ids); + List findByGameBoardIdOrderByIdAsc(Long gameBoardId); + long countByGameBoardId(Long gameBoardId); @Modifying(flushAutomatically = true) diff --git a/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java b/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java new file mode 100644 index 000000000..487275136 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java @@ -0,0 +1,75 @@ +package umc.cockple.demo.domain.game.service.query; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.game.domain.Game; +import umc.cockple.demo.domain.game.domain.GameBoard; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.enums.GameMatchType; +import umc.cockple.demo.domain.game.enums.GameStatus; +import umc.cockple.demo.domain.game.exception.GameErrorCode; +import umc.cockple.demo.domain.game.exception.GameException; +import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; +import umc.cockple.demo.domain.game.repository.GameRepository; +import umc.cockple.demo.domain.game.service.query.result.GameRandomMatchResult; +import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator; +import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator.GamePairHistory; +import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; +import umc.cockple.demo.domain.game.service.support.selector.GameBestMatchSelector; +import umc.cockple.demo.domain.game.service.support.selector.GameCandidatePoolSelector; +import umc.cockple.demo.domain.game.service.support.selector.GameMatchTypeSelector; +import umc.cockple.demo.domain.game.service.support.validator.GameBoardAccessValidator; +import umc.cockple.demo.domain.game.service.support.validator.GameBoardMemberAvailabilityPolicy; +import umc.cockple.demo.global.enums.Level; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GameRandomMatchQueryService { + + private static final List ACTIVE_STATUSES = + List.of(GameStatus.WAITING, GameStatus.PLAYING); + private static final List COMPLETED_ONLY = List.of(GameStatus.COMPLETED); + + private final GameBoardReader gameBoardReader; + private final GameBoardMemberRepository gameBoardMemberRepository; + private final GameRepository gameRepository; + private final GameBoardAccessValidator gameBoardAccessValidator; + private final GameBoardMemberAvailabilityPolicy availabilityPolicy; + private final GameMatchTypeSelector matchTypeSelector; + private final GameCandidatePoolSelector candidatePoolSelector; + private final GamePairHistoryCalculator pairHistoryCalculator; + private final GameBestMatchSelector bestMatchSelector; + + public GameRandomMatchResult match(Long memberId, Long gameBoardId) { + gameBoardAccessValidator.validateGameHost(gameBoardId, memberId); + GameBoard gameBoard = gameBoardReader.read(gameBoardId); + LocalDateTime now = LocalDateTime.now(); + + List members = gameBoardMemberRepository + .findByGameBoardIdOrderByIdAsc(gameBoard.getId()); + List activeGames = gameRepository.findByGameBoardIdAndStatusInWithPlayers( + gameBoard.getId(), ACTIVE_STATUSES); + List candidates = availabilityPolicy + .filterAvailable(members, activeGames, now).stream() + .filter(member -> member.getLevel() != Level.NONE) + .toList(); + if (candidates.size() < 4) { + throw new GameException(GameErrorCode.INSUFFICIENT_AVAILABLE_PLAYERS); + } + + GameMatchType matchType = matchTypeSelector.select(candidates); + List candidatePool = candidatePoolSelector.select(candidates, matchType); + List completedGames = gameRepository.findByGameBoardIdAndStatusInWithPlayers( + gameBoard.getId(), COMPLETED_ONLY); + GamePairHistory pairHistory = pairHistoryCalculator.calculate(completedGames); + List matchedMemberIds = bestMatchSelector.select( + candidatePool, matchType, pairHistory); + + return new GameRandomMatchResult(matchedMemberIds); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/game/service/query/result/GameRandomMatchResult.java b/src/main/java/umc/cockple/demo/domain/game/service/query/result/GameRandomMatchResult.java new file mode 100644 index 000000000..58fd5f72e --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/service/query/result/GameRandomMatchResult.java @@ -0,0 +1,6 @@ +package umc.cockple.demo.domain.game.service.query.result; + +import java.util.List; + +public record GameRandomMatchResult(List gameBoardMemberIds) { +} diff --git a/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java new file mode 100644 index 000000000..cf50351e7 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java @@ -0,0 +1,174 @@ +package umc.cockple.demo.domain.game.service.query; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.game.domain.Game; +import umc.cockple.demo.domain.game.domain.GameBoard; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.enums.AgeGroup; +import umc.cockple.demo.domain.game.enums.GameMatchType; +import umc.cockple.demo.domain.game.enums.GameStatus; +import umc.cockple.demo.domain.game.exception.GameErrorCode; +import umc.cockple.demo.domain.game.exception.GameException; +import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; +import umc.cockple.demo.domain.game.repository.GameRepository; +import umc.cockple.demo.domain.game.service.query.result.GameRandomMatchResult; +import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator; +import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator.GamePairHistory; +import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; +import umc.cockple.demo.domain.game.service.support.selector.GameBestMatchSelector; +import umc.cockple.demo.domain.game.service.support.selector.GameCandidatePoolSelector; +import umc.cockple.demo.domain.game.service.support.selector.GameMatchTypeSelector; +import umc.cockple.demo.domain.game.service.support.validator.GameBoardAccessValidator; +import umc.cockple.demo.domain.game.service.support.validator.GameBoardMemberAvailabilityPolicy; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.GameFixture; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GameRandomMatchQueryService") +class GameRandomMatchQueryServiceTest { + + private static final Long MEMBER_ID = 10L; + private static final Long BOARD_ID = 1L; + private static final List ACTIVE_STATUSES = + List.of(GameStatus.WAITING, GameStatus.PLAYING); + private static final List COMPLETED_ONLY = List.of(GameStatus.COMPLETED); + + @Mock private GameBoardReader gameBoardReader; + @Mock private GameBoardMemberRepository gameBoardMemberRepository; + @Mock private GameRepository gameRepository; + @Mock private GameBoardAccessValidator gameBoardAccessValidator; + @Mock private GameBoardMemberAvailabilityPolicy availabilityPolicy; + @Mock private GameMatchTypeSelector matchTypeSelector; + @Mock private GameCandidatePoolSelector candidatePoolSelector; + @Mock private GamePairHistoryCalculator pairHistoryCalculator; + @Mock private GameBestMatchSelector bestMatchSelector; + + private GameRandomMatchQueryService service; + private GameBoard board; + + @BeforeEach + void setUp() { + board = GameFixture.gameBoard(BOARD_ID); + service = new GameRandomMatchQueryService( + gameBoardReader, + gameBoardMemberRepository, + gameRepository, + gameBoardAccessValidator, + availabilityPolicy, + matchTypeSelector, + candidatePoolSelector, + pairHistoryCalculator, + bestMatchSelector); + } + + @Test + @DisplayName("가용한 급수 보유 선수로 후보 풀과 최적 조합을 순서대로 계산한다") + void match_orchestratesRandomMatching() { + List members = List.of( + member(1L, Level.NONE), + member(2L, Level.A), + member(3L, Level.B), + member(4L, Level.C), + member(5L, Level.D)); + List candidates = members.subList(1, 5); + List candidatePool = List.copyOf(candidates); + List activeGames = List.of(); + List completedGames = List.of(); + GamePairHistory pairHistory = new GamePairHistoryCalculator().calculate(List.of()); + + given(gameBoardReader.read(BOARD_ID)).willReturn(board); + given(gameBoardMemberRepository.findByGameBoardIdOrderByIdAsc(BOARD_ID)) + .willReturn(members); + given(gameRepository.findByGameBoardIdAndStatusInWithPlayers( + BOARD_ID, ACTIVE_STATUSES)).willReturn(activeGames); + given(availabilityPolicy.filterAvailable( + eq(members), eq(activeGames), any(LocalDateTime.class))).willReturn(members); + given(matchTypeSelector.select(candidates)).willReturn(GameMatchType.MEN_DOUBLES); + given(candidatePoolSelector.select(candidates, GameMatchType.MEN_DOUBLES)) + .willReturn(candidatePool); + given(gameRepository.findByGameBoardIdAndStatusInWithPlayers( + BOARD_ID, COMPLETED_ONLY)).willReturn(completedGames); + given(pairHistoryCalculator.calculate(completedGames)).willReturn(pairHistory); + given(bestMatchSelector.select( + candidatePool, GameMatchType.MEN_DOUBLES, pairHistory)) + .willReturn(List.of(2L, 3L, 4L, 5L)); + + GameRandomMatchResult result = service.match(MEMBER_ID, BOARD_ID); + + assertThat(result.gameBoardMemberIds()).containsExactly(2L, 3L, 4L, 5L); + then(gameBoardAccessValidator).should().validateGameHost(BOARD_ID, MEMBER_ID); + then(availabilityPolicy).should().filterAvailable( + eq(members), eq(activeGames), any(LocalDateTime.class)); + then(matchTypeSelector).should().select(candidates); + then(candidatePoolSelector).should() + .select(candidates, GameMatchType.MEN_DOUBLES); + then(pairHistoryCalculator).should().calculate(completedGames); + then(bestMatchSelector).should() + .select(candidatePool, GameMatchType.MEN_DOUBLES, pairHistory); + then(gameRepository).should(never()).save(any(Game.class)); + then(gameBoardMemberRepository).should(never()).save(any(GameBoardMember.class)); + } + + @Test + @DisplayName("가용한 급수 보유 선수가 4명 미만이면 GAME415 예외를 던진다") + void match_rejectsInsufficientAvailablePlayers() { + List members = List.of( + member(1L, Level.A), + member(2L, Level.B), + member(3L, Level.C), + member(4L, Level.NONE)); + List activeGames = List.of(); + + given(gameBoardReader.read(BOARD_ID)).willReturn(board); + given(gameBoardMemberRepository.findByGameBoardIdOrderByIdAsc(BOARD_ID)) + .willReturn(members); + given(gameRepository.findByGameBoardIdAndStatusInWithPlayers( + BOARD_ID, ACTIVE_STATUSES)).willReturn(activeGames); + given(availabilityPolicy.filterAvailable( + eq(members), eq(activeGames), any(LocalDateTime.class))).willReturn(members); + + assertThatThrownBy(() -> service.match(MEMBER_ID, BOARD_ID)) + .isInstanceOfSatisfying(GameException.class, exception -> + assertThat(exception.getCode()) + .isEqualTo(GameErrorCode.INSUFFICIENT_AVAILABLE_PLAYERS)); + + then(matchTypeSelector).should(never()).select(anyList()); + then(candidatePoolSelector).shouldHaveNoInteractions(); + then(pairHistoryCalculator).shouldHaveNoInteractions(); + then(bestMatchSelector).shouldHaveNoInteractions(); + then(gameRepository).should(never()).findByGameBoardIdAndStatusInWithPlayers( + BOARD_ID, COMPLETED_ONLY); + } + + private GameBoardMember member(Long id, Level level) { + return GameBoardMember.builder() + .id(id) + .gameBoard(board) + .name("선수" + id) + .gender(Gender.MALE) + .level(level) + .ageGroup(AgeGroup.TWENTIES) + .participating(true) + .gameCount(0) + .shuttlecockSubmitted(false) + .build(); + } +} From abed535d34a04b08ce6301c892b5bd5813932b12 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Mon, 24 Aug 2026 22:57:17 +0900 Subject: [PATCH 09/14] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=9E=84=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=EB=A5=BC=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EB=82=B4=EB=B6=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service}/GameBoardMemberAvailabilityPolicy.java | 2 +- .../service}/GamePairHistoryCalculator.java | 2 +- .../matching}/GameAbilityScoreCalculator.java | 2 +- .../service/matching}/GameBestMatchSelector.java | 5 ++--- .../service/matching}/GameCandidatePoolSelector.java | 2 +- .../service/matching}/GameMatchTypeSelector.java | 2 +- .../query/GameDuplicateCheckQueryService.java | 4 ++-- .../service/query/GameRandomMatchQueryService.java | 12 ++++++------ .../GameBoardMemberAvailabilityPolicyTest.java | 2 +- .../service}/GamePairHistoryCalculatorTest.java | 4 ++-- .../matching}/GameAbilityScoreCalculatorTest.java | 2 +- .../service/matching}/GameBestMatchSelectorTest.java | 7 +++---- .../matching}/GameCandidatePoolSelectorTest.java | 2 +- .../service/matching}/GameMatchTypeSelectorTest.java | 2 +- .../game/service/command/GameCommandServiceTest.java | 1 - .../query/GameDuplicateCheckQueryServiceTest.java | 2 +- .../query/GameRandomMatchQueryServiceTest.java | 12 ++++++------ 17 files changed, 31 insertions(+), 34 deletions(-) rename src/main/java/umc/cockple/demo/domain/game/{service/support/validator => domain/service}/GameBoardMemberAvailabilityPolicy.java (97%) rename src/main/java/umc/cockple/demo/domain/game/{service/support/calculator => domain/service}/GamePairHistoryCalculator.java (97%) rename src/main/java/umc/cockple/demo/domain/game/{service/support/calculator => domain/service/matching}/GameAbilityScoreCalculator.java (95%) rename src/main/java/umc/cockple/demo/domain/game/{service/support/selector => domain/service/matching}/GameBestMatchSelector.java (96%) rename src/main/java/umc/cockple/demo/domain/game/{service/support/selector => domain/service/matching}/GameCandidatePoolSelector.java (98%) rename src/main/java/umc/cockple/demo/domain/game/{service/support/selector => domain/service/matching}/GameMatchTypeSelector.java (96%) rename src/test/java/umc/cockple/demo/domain/game/{service/support/validator => domain/service}/GameBoardMemberAvailabilityPolicyTest.java (98%) rename src/test/java/umc/cockple/demo/domain/game/{service/support/calculator => domain/service}/GamePairHistoryCalculatorTest.java (95%) rename src/test/java/umc/cockple/demo/domain/game/{service/support/calculator => domain/service/matching}/GameAbilityScoreCalculatorTest.java (98%) rename src/test/java/umc/cockple/demo/domain/game/{service/support/selector => domain/service/matching}/GameBestMatchSelectorTest.java (96%) rename src/test/java/umc/cockple/demo/domain/game/{service/support/selector => domain/service/matching}/GameCandidatePoolSelectorTest.java (99%) rename src/test/java/umc/cockple/demo/domain/game/{service/support/selector => domain/service/matching}/GameMatchTypeSelectorTest.java (98%) diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicy.java b/src/main/java/umc/cockple/demo/domain/game/domain/service/GameBoardMemberAvailabilityPolicy.java similarity index 97% rename from src/main/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicy.java rename to src/main/java/umc/cockple/demo/domain/game/domain/service/GameBoardMemberAvailabilityPolicy.java index 7054f0ce7..7545e080c 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicy.java +++ b/src/main/java/umc/cockple/demo/domain/game/domain/service/GameBoardMemberAvailabilityPolicy.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.game.service.support.validator; +package umc.cockple.demo.domain.game.domain.service; import org.springframework.stereotype.Component; import umc.cockple.demo.domain.game.domain.Game; diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculator.java b/src/main/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculator.java similarity index 97% rename from src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculator.java rename to src/main/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculator.java index 31c83becb..0ee83b7d6 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculator.java +++ b/src/main/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculator.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.game.service.support.calculator; +package umc.cockple.demo.domain.game.domain.service; import org.springframework.stereotype.Component; import umc.cockple.demo.domain.game.domain.Game; diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculator.java b/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameAbilityScoreCalculator.java similarity index 95% rename from src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculator.java rename to src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameAbilityScoreCalculator.java index 92a81960e..69a07f5c9 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculator.java +++ b/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameAbilityScoreCalculator.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.game.service.support.calculator; +package umc.cockple.demo.domain.game.domain.service.matching; import org.springframework.stereotype.Component; import umc.cockple.demo.domain.game.domain.GameBoardMember; diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelector.java b/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameBestMatchSelector.java similarity index 96% rename from src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelector.java rename to src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameBestMatchSelector.java index 321b08963..8b1f95333 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelector.java +++ b/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameBestMatchSelector.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.game.service.support.selector; +package umc.cockple.demo.domain.game.domain.service.matching; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -6,8 +6,7 @@ import umc.cockple.demo.domain.game.enums.GameMatchType; import umc.cockple.demo.domain.game.exception.GameErrorCode; import umc.cockple.demo.domain.game.exception.GameException; -import umc.cockple.demo.domain.game.service.support.calculator.GameAbilityScoreCalculator; -import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator.GamePairHistory; +import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator.GamePairHistory; import umc.cockple.demo.global.enums.Gender; import java.util.List; diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelector.java b/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameCandidatePoolSelector.java similarity index 98% rename from src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelector.java rename to src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameCandidatePoolSelector.java index d8d1ca04d..bf0f9bd7a 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelector.java +++ b/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameCandidatePoolSelector.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.game.service.support.selector; +package umc.cockple.demo.domain.game.domain.service.matching; import org.springframework.stereotype.Component; import umc.cockple.demo.domain.game.domain.GameBoardMember; diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelector.java b/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelector.java similarity index 96% rename from src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelector.java rename to src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelector.java index 4b67b1fae..4a9db4005 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelector.java +++ b/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelector.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.game.service.support.selector; +package umc.cockple.demo.domain.game.domain.service.matching; import org.springframework.stereotype.Component; import umc.cockple.demo.domain.game.domain.GameBoardMember; diff --git a/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java b/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java index 932c32b8b..d89f8717e 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java @@ -11,8 +11,8 @@ import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; import umc.cockple.demo.domain.game.repository.GameRepository; import umc.cockple.demo.domain.game.service.query.result.GameDuplicateCheckResult; -import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator; -import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator.GamePairHistory; +import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator; +import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator.GamePairHistory; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; import java.util.ArrayList; diff --git a/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java b/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java index 487275136..3938ae8fd 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java @@ -13,14 +13,14 @@ import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; import umc.cockple.demo.domain.game.repository.GameRepository; import umc.cockple.demo.domain.game.service.query.result.GameRandomMatchResult; -import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator; -import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator.GamePairHistory; +import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator; +import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator.GamePairHistory; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; -import umc.cockple.demo.domain.game.service.support.selector.GameBestMatchSelector; -import umc.cockple.demo.domain.game.service.support.selector.GameCandidatePoolSelector; -import umc.cockple.demo.domain.game.service.support.selector.GameMatchTypeSelector; +import umc.cockple.demo.domain.game.domain.service.matching.GameBestMatchSelector; +import umc.cockple.demo.domain.game.domain.service.matching.GameCandidatePoolSelector; +import umc.cockple.demo.domain.game.domain.service.matching.GameMatchTypeSelector; import umc.cockple.demo.domain.game.service.support.validator.GameBoardAccessValidator; -import umc.cockple.demo.domain.game.service.support.validator.GameBoardMemberAvailabilityPolicy; +import umc.cockple.demo.domain.game.domain.service.GameBoardMemberAvailabilityPolicy; import umc.cockple.demo.global.enums.Level; import java.time.LocalDateTime; diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicyTest.java b/src/test/java/umc/cockple/demo/domain/game/domain/service/GameBoardMemberAvailabilityPolicyTest.java similarity index 98% rename from src/test/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicyTest.java rename to src/test/java/umc/cockple/demo/domain/game/domain/service/GameBoardMemberAvailabilityPolicyTest.java index a0c1141f6..58f105654 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/support/validator/GameBoardMemberAvailabilityPolicyTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/domain/service/GameBoardMemberAvailabilityPolicyTest.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.game.service.support.validator; +package umc.cockple.demo.domain.game.domain.service; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculatorTest.java b/src/test/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculatorTest.java similarity index 95% rename from src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculatorTest.java rename to src/test/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculatorTest.java index 876805e52..ebc064af8 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GamePairHistoryCalculatorTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculatorTest.java @@ -1,11 +1,11 @@ -package umc.cockple.demo.domain.game.service.support.calculator; +package umc.cockple.demo.domain.game.domain.service; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import umc.cockple.demo.domain.game.domain.Game; import umc.cockple.demo.domain.game.domain.GameBoard; import umc.cockple.demo.domain.game.domain.GameBoardMember; -import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator.GamePairHistory; +import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator.GamePairHistory; import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.support.fixture.GameFixture; diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculatorTest.java b/src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameAbilityScoreCalculatorTest.java similarity index 98% rename from src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculatorTest.java rename to src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameAbilityScoreCalculatorTest.java index 767da027a..8b2bfeb6a 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/support/calculator/GameAbilityScoreCalculatorTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameAbilityScoreCalculatorTest.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.game.service.support.calculator; +package umc.cockple.demo.domain.game.domain.service.matching; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelectorTest.java b/src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameBestMatchSelectorTest.java similarity index 96% rename from src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelectorTest.java rename to src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameBestMatchSelectorTest.java index 04a603d74..5ed875fc6 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameBestMatchSelectorTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameBestMatchSelectorTest.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.game.service.support.selector; +package umc.cockple.demo.domain.game.domain.service.matching; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -10,9 +10,8 @@ import umc.cockple.demo.domain.game.enums.GameMatchType; import umc.cockple.demo.domain.game.exception.GameErrorCode; import umc.cockple.demo.domain.game.exception.GameException; -import umc.cockple.demo.domain.game.service.support.calculator.GameAbilityScoreCalculator; -import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator; -import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator.GamePairHistory; +import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator; +import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator.GamePairHistory; import umc.cockple.demo.global.enums.Gender; import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.support.fixture.GameFixture; diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelectorTest.java b/src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameCandidatePoolSelectorTest.java similarity index 99% rename from src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelectorTest.java rename to src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameCandidatePoolSelectorTest.java index c67bd7678..2f3299a2a 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameCandidatePoolSelectorTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameCandidatePoolSelectorTest.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.game.service.support.selector; +package umc.cockple.demo.domain.game.domain.service.matching; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelectorTest.java b/src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelectorTest.java similarity index 98% rename from src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelectorTest.java rename to src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelectorTest.java index 86ec00cae..6a699582a 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/support/selector/GameMatchTypeSelectorTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelectorTest.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.game.service.support.selector; +package umc.cockple.demo.domain.game.domain.service.matching; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java index a8ad9b525..030f6a775 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java @@ -44,7 +44,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; -import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) diff --git a/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java index 6c87caa8d..69090f912 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java @@ -14,7 +14,7 @@ import umc.cockple.demo.domain.game.repository.GameRepository; import umc.cockple.demo.domain.game.service.query.result.GameDuplicateCheckResult; import umc.cockple.demo.domain.game.service.query.result.GameDuplicateCheckResult.PairView; -import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator; +import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.support.fixture.GameFixture; diff --git a/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java index cf50351e7..186eebba2 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java @@ -17,14 +17,14 @@ import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; import umc.cockple.demo.domain.game.repository.GameRepository; import umc.cockple.demo.domain.game.service.query.result.GameRandomMatchResult; -import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator; -import umc.cockple.demo.domain.game.service.support.calculator.GamePairHistoryCalculator.GamePairHistory; +import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator; +import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator.GamePairHistory; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; -import umc.cockple.demo.domain.game.service.support.selector.GameBestMatchSelector; -import umc.cockple.demo.domain.game.service.support.selector.GameCandidatePoolSelector; -import umc.cockple.demo.domain.game.service.support.selector.GameMatchTypeSelector; +import umc.cockple.demo.domain.game.domain.service.matching.GameBestMatchSelector; +import umc.cockple.demo.domain.game.domain.service.matching.GameCandidatePoolSelector; +import umc.cockple.demo.domain.game.domain.service.matching.GameMatchTypeSelector; import umc.cockple.demo.domain.game.service.support.validator.GameBoardAccessValidator; -import umc.cockple.demo.domain.game.service.support.validator.GameBoardMemberAvailabilityPolicy; +import umc.cockple.demo.domain.game.domain.service.GameBoardMemberAvailabilityPolicy; import umc.cockple.demo.global.enums.Gender; import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.support.fixture.GameFixture; From edc7a87badee119c3a9c54cc0ccea4ea8fdbb408 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Mon, 24 Aug 2026 23:01:32 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20=EB=9E=9C?= =?UTF-8?q?=EB=8D=A4=20=EB=A7=A4=EC=B9=AD=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/GameController.java | 15 ++ .../presentation/controller/api/GameApi.java | 21 ++ .../presentation/dto/GameRandomMatchDTO.java | 11 + .../mapper/GameRandomMatchMapper.java | 13 ++ .../GameRandomMatchIntegrationTest.java | 188 ++++++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 src/main/java/umc/cockple/demo/domain/game/presentation/dto/GameRandomMatchDTO.java create mode 100644 src/main/java/umc/cockple/demo/domain/game/presentation/mapper/GameRandomMatchMapper.java create mode 100644 src/test/java/umc/cockple/demo/domain/game/integration/GameRandomMatchIntegrationTest.java diff --git a/src/main/java/umc/cockple/demo/domain/game/presentation/controller/GameController.java b/src/main/java/umc/cockple/demo/domain/game/presentation/controller/GameController.java index 678525a81..0575955a5 100644 --- a/src/main/java/umc/cockple/demo/domain/game/presentation/controller/GameController.java +++ b/src/main/java/umc/cockple/demo/domain/game/presentation/controller/GameController.java @@ -6,12 +6,16 @@ import umc.cockple.demo.domain.game.presentation.controller.api.GameApi; import umc.cockple.demo.domain.game.presentation.dto.GameCompletedGameDTO; import umc.cockple.demo.domain.game.presentation.dto.GameDuplicateCheckDTO; +import umc.cockple.demo.domain.game.presentation.dto.GameRandomMatchDTO; import umc.cockple.demo.domain.game.presentation.mapper.GameCompletedGameMapper; import umc.cockple.demo.domain.game.presentation.mapper.GameDuplicateCheckMapper; +import umc.cockple.demo.domain.game.presentation.mapper.GameRandomMatchMapper; import umc.cockple.demo.domain.game.service.query.GameCompletedGameQueryService; import umc.cockple.demo.domain.game.service.query.GameDuplicateCheckQueryService; +import umc.cockple.demo.domain.game.service.query.GameRandomMatchQueryService; import umc.cockple.demo.domain.game.service.query.result.GameCompletedGameResult; import umc.cockple.demo.domain.game.service.query.result.GameDuplicateCheckResult; +import umc.cockple.demo.domain.game.service.query.result.GameRandomMatchResult; import umc.cockple.demo.global.response.BaseResponse; import umc.cockple.demo.global.response.code.status.CommonSuccessCode; import umc.cockple.demo.global.security.utils.SecurityUtil; @@ -26,6 +30,17 @@ public class GameController implements GameApi { private final GameDuplicateCheckMapper gameDuplicateCheckMapper; private final GameCompletedGameQueryService gameCompletedGameQueryService; private final GameCompletedGameMapper gameCompletedGameMapper; + private final GameRandomMatchQueryService gameRandomMatchQueryService; + private final GameRandomMatchMapper gameRandomMatchMapper; + + @Override + public ResponseEntity> randomMatch(Long gameBoardId) { + Long memberId = SecurityUtil.getCurrentMemberId(); + + GameRandomMatchResult result = gameRandomMatchQueryService.match(memberId, gameBoardId); + + return BaseResponse.of(CommonSuccessCode.OK, gameRandomMatchMapper.toResponse(result)); + } @Override public ResponseEntity> checkDuplicates( diff --git a/src/main/java/umc/cockple/demo/domain/game/presentation/controller/api/GameApi.java b/src/main/java/umc/cockple/demo/domain/game/presentation/controller/api/GameApi.java index 66a041f53..46036ea35 100644 --- a/src/main/java/umc/cockple/demo/domain/game/presentation/controller/api/GameApi.java +++ b/src/main/java/umc/cockple/demo/domain/game/presentation/controller/api/GameApi.java @@ -5,10 +5,12 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import umc.cockple.demo.domain.game.presentation.dto.GameCompletedGameDTO; import umc.cockple.demo.domain.game.presentation.dto.GameDuplicateCheckDTO; +import umc.cockple.demo.domain.game.presentation.dto.GameRandomMatchDTO; import umc.cockple.demo.global.response.BaseResponse; import java.util.List; @@ -17,6 +19,25 @@ @GameApiTag public interface GameApi { + @PostMapping("/{gameBoardId}/games/random-match") + @Operation(summary = "게임 랜덤 매칭", description = """ + 현재 게임판 명단에서 바로 게임에 참여할 수 있는 선수 4명을 추천합니다. + + - 게임 진행자만 호출할 수 있습니다. + - 불참, 대기 중, 경기 시작 후 10분 미만인 선수와 급수없는 선수는 후보에서 제외합니다. + - 혼복·남복·여복 중 가능한 타입 하나를 내부에서 무작위로 선택합니다. + - 응답 ID는 팀 구분 없이 오름차순이며 매치 타입은 노출하지 않습니다. + - 추천 결과는 저장하지 않습니다. 확정 시 기존 WebSocket `CREATE_GAME`을 호출해야 합니다. + """) + @ApiResponse(responseCode = "200", description = "랜덤 매칭 성공") + @ApiResponse(responseCode = "400", description = "가용 인원·성별 구성 부족 또는 조합 생성 실패") + @ApiResponse(responseCode = "401", description = "인증 필요") + @ApiResponse(responseCode = "403", description = "게임 진행자가 아닌 회원") + @ApiResponse(responseCode = "404", description = "게임판을 찾을 수 없음") + ResponseEntity> randomMatch( + @PathVariable Long gameBoardId + ); + @GetMapping("/{gameBoardId}/games/duplicate-check") @Operation(summary = "게임 중복 체크", description = """ 새 게임 인원을 고를 때, 선택한 멤버들의 쌍별 대전 이력을 반환합니다. (반복 매칭 회피용) diff --git a/src/main/java/umc/cockple/demo/domain/game/presentation/dto/GameRandomMatchDTO.java b/src/main/java/umc/cockple/demo/domain/game/presentation/dto/GameRandomMatchDTO.java new file mode 100644 index 000000000..bede39a19 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/presentation/dto/GameRandomMatchDTO.java @@ -0,0 +1,11 @@ +package umc.cockple.demo.domain.game.presentation.dto; + +import java.util.List; + +public class GameRandomMatchDTO { + + public record Response( + List gameBoardMemberIds + ) { + } +} diff --git a/src/main/java/umc/cockple/demo/domain/game/presentation/mapper/GameRandomMatchMapper.java b/src/main/java/umc/cockple/demo/domain/game/presentation/mapper/GameRandomMatchMapper.java new file mode 100644 index 000000000..26ed395e4 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/presentation/mapper/GameRandomMatchMapper.java @@ -0,0 +1,13 @@ +package umc.cockple.demo.domain.game.presentation.mapper; + +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.game.presentation.dto.GameRandomMatchDTO; +import umc.cockple.demo.domain.game.service.query.result.GameRandomMatchResult; + +@Component +public class GameRandomMatchMapper { + + public GameRandomMatchDTO.Response toResponse(GameRandomMatchResult result) { + return new GameRandomMatchDTO.Response(result.gameBoardMemberIds()); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/game/integration/GameRandomMatchIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/game/integration/GameRandomMatchIntegrationTest.java new file mode 100644 index 000000000..245355c8b --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/game/integration/GameRandomMatchIntegrationTest.java @@ -0,0 +1,188 @@ +package umc.cockple.demo.domain.game.integration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.enums.AgeGroup; +import umc.cockple.demo.domain.game.exception.GameErrorCode; +import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; +import umc.cockple.demo.domain.game.repository.GameRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.aMapWithSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Transactional +@DisplayName("게임 랜덤 매칭 API 통합 테스트") +class GameRandomMatchIntegrationTest extends IntegrationTestBase { + + @Autowired private MockMvc mockMvc; + @Autowired private MemberRepository memberRepository; + @Autowired private PartyAddrRepository partyAddrRepository; + @Autowired private PartyRepository partyRepository; + @Autowired private ExerciseRepository exerciseRepository; + @Autowired private GameBoardMemberRepository gameBoardMemberRepository; + @Autowired private GameRepository gameRepository; + + private Member gameHost; + private Member otherMember; + private Exercise exercise; + private List members; + + @BeforeEach + void setUp() { + gameHost = memberRepository.save(MemberFixture.createMemberWithName( + "랜덤 진행자", "랜덤진행자", Gender.MALE, Level.A, 71901L)); + otherMember = memberRepository.save(MemberFixture.createMemberWithName( + "일반 회원", "일반회원", Gender.FEMALE, Level.B, 71902L)); + + PartyAddr partyAddr = partyAddrRepository.save( + PartyFixture.createPartyAddr("서울특별시", "강남구")); + Party party = partyRepository.save( + PartyFixture.createParty("랜덤 매칭 테스트 모임", gameHost.getId(), partyAddr)); + exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 12, 31))); + + members = new ArrayList<>(); + for (int index = 1; index <= 4; index++) { + members.add(gameBoardMemberRepository.save(GameBoardMember.builder() + .gameBoard(exercise.getGameBoard()) + .name("선수" + index) + .gender(Gender.MALE) + .level(Level.A) + .ageGroup(AgeGroup.TWENTIES) + .shuttlecockSubmitted(false) + .participating(true) + .gameCount(0) + .build())); + } + } + + @AfterEach + void clearAuthentication() { + SecurityContextHelper.clearAuthentication(); + } + + @Test + @DisplayName("게임 진행자에게 오름차순 명단 ID 4개만 반환하고 게임은 저장하지 않는다") + void randomMatch_returnsIdsWithoutPersistingGame() throws Exception { + authenticate(gameHost); + + mockMvc.perform(post( + "/api/game-boards/{gameBoardId}/games/random-match", + exercise.getGameBoard().getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", aMapWithSize(1))) + .andExpect(jsonPath("$.data.gameBoardMemberIds.length()").value(4)) + .andExpect(jsonPath("$.data.gameBoardMemberIds[0]").value(members.get(0).getId())) + .andExpect(jsonPath("$.data.gameBoardMemberIds[1]").value(members.get(1).getId())) + .andExpect(jsonPath("$.data.gameBoardMemberIds[2]").value(members.get(2).getId())) + .andExpect(jsonPath("$.data.gameBoardMemberIds[3]").value(members.get(3).getId())) + .andExpect(jsonPath("$.data.matchType").doesNotExist()); + + assertThat(gameRepository.count()).isZero(); + } + + @Test + @DisplayName("급수없음 제외 후 후보가 4명 미만이면 GAME415를 반환한다") + void randomMatch_rejectsInsufficientAvailablePlayers() throws Exception { + GameBoardMember excluded = members.get(3); + excluded.updateInfo(excluded.getName(), excluded.getGender(), Level.NONE, excluded.getAgeGroup()); + authenticate(gameHost); + + expectBadRequest(GameErrorCode.INSUFFICIENT_AVAILABLE_PLAYERS); + } + + @Test + @DisplayName("성별 구성이 남자 3명 여자 1명이면 GAME416을 반환한다") + void randomMatch_rejectsInsufficientGenderComposition() throws Exception { + GameBoardMember female = members.get(3); + female.updateInfo(female.getName(), Gender.FEMALE, female.getLevel(), female.getAgeGroup()); + authenticate(gameHost); + + expectBadRequest(GameErrorCode.INSUFFICIENT_GENDER_COMPOSITION); + } + + @Test + @DisplayName("최소 경기 수 +5 안에 4명이 모이지 않으면 GAME417을 반환한다") + void randomMatch_rejectsWhenCandidateExpansionFails() throws Exception { + increaseGameCount(members.get(1), 1); + increaseGameCount(members.get(2), 5); + increaseGameCount(members.get(3), 6); + authenticate(gameHost); + + expectBadRequest(GameErrorCode.RANDOM_MATCH_NOT_FOUND); + } + + @Test + @DisplayName("게임 진행자가 아닌 회원은 403을 반환한다") + void randomMatch_deniesNonGameHost() throws Exception { + authenticate(otherMember); + + mockMvc.perform(post("/api/game-boards/{gameBoardId}/games/random-match", + exercise.getGameBoard().getId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(GameErrorCode.GAME_BOARD_ACCESS_DENIED.getCode())); + } + + @Test + @DisplayName("미인증 요청은 401을 반환한다") + void randomMatch_requiresAuthentication() throws Exception { + mockMvc.perform(post("/api/game-boards/{gameBoardId}/games/random-match", + exercise.getGameBoard().getId())) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("존재하지 않는 게임판은 404를 반환한다") + void randomMatch_rejectsMissingGameBoard() throws Exception { + authenticate(gameHost); + + mockMvc.perform(post("/api/game-boards/{gameBoardId}/games/random-match", Long.MAX_VALUE)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(GameErrorCode.GAME_BOARD_NOT_FOUND.getCode())); + } + + private void expectBadRequest(GameErrorCode errorCode) throws Exception { + mockMvc.perform(post("/api/game-boards/{gameBoardId}/games/random-match", + exercise.getGameBoard().getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(errorCode.getCode())); + } + + private void increaseGameCount(GameBoardMember member, int count) { + for (int index = 0; index < count; index++) { + member.increaseGameCount(); + } + } + + private void authenticate(Member member) { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + } +} From 0c549c617e57822fe485bc53d580f84e496492f8 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Tue, 25 Aug 2026 06:46:23 +0900 Subject: [PATCH 11/14] =?UTF-8?q?fix:=20=EB=A7=A4=EC=B9=98=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9D=84=20=EC=88=9C=ED=9A=8C=ED=95=98=EB=A9=B4?= =?UTF-8?q?=EC=84=9C=20=ED=9B=84=EB=B3=B4=EA=B5=B0=EC=9D=84=20=EC=B0=BE?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../matching/GameCandidatePoolSelector.java | 13 +++- .../matching/GameMatchTypeSelector.java | 8 ++- .../query/GameRandomMatchQueryService.java | 21 +++++- .../matching/GameMatchTypeSelectorTest.java | 17 +++-- .../GameRandomMatchQueryServiceTest.java | 71 ++++++++++++++++--- 5 files changed, 112 insertions(+), 18 deletions(-) diff --git a/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameCandidatePoolSelector.java b/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameCandidatePoolSelector.java index bf0f9bd7a..666abc79d 100644 --- a/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameCandidatePoolSelector.java +++ b/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameCandidatePoolSelector.java @@ -12,6 +12,7 @@ import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; @Component @@ -28,13 +29,19 @@ public class GameCandidatePoolSelector { public List select( List availableMembers, GameMatchType matchType) { + return find(availableMembers, matchType).orElseThrow(this::randomMatchNotFound); + } + + public Optional> find( + List availableMembers, + GameMatchType matchType) { List sortedCandidates = availableMembers.stream() .filter(member -> member.getLevel() != Level.NONE) .filter(member -> belongsToMatchType(member, matchType)) .sorted(FAIRNESS_ORDER) .toList(); if (sortedCandidates.isEmpty()) { - throw randomMatchNotFound(); + return Optional.empty(); } int minimumGameCount = sortedCandidates.get(0).getGameCount(); @@ -44,10 +51,10 @@ public List select( .filter(member -> member.getGameCount() <= maximumGameCount) .toList(); if (hasRequiredComposition(expandedPool, matchType)) { - return limitSize(expandedPool, matchType); + return Optional.of(limitSize(expandedPool, matchType)); } } - throw randomMatchNotFound(); + return Optional.empty(); } private boolean belongsToMatchType(GameBoardMember member, GameMatchType matchType) { diff --git a/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelector.java b/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelector.java index 4a9db4005..4851c19b6 100644 --- a/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelector.java +++ b/src/main/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelector.java @@ -14,7 +14,7 @@ @Component public class GameMatchTypeSelector { - public GameMatchType select(List candidates) { + public List findAvailableTypes(List candidates) { long maleCount = countByGender(candidates, Gender.MALE); long femaleCount = countByGender(candidates, Gender.FEMALE); @@ -31,7 +31,13 @@ public GameMatchType select(List candidates) { if (availableTypes.isEmpty()) { throw new GameException(GameErrorCode.INSUFFICIENT_GENDER_COMPOSITION); } + return availableTypes; + } + public GameMatchType selectFrom(List availableTypes) { + if (availableTypes.isEmpty()) { + throw new GameException(GameErrorCode.INSUFFICIENT_GENDER_COMPOSITION); + } int selectedIndex = ThreadLocalRandom.current().nextInt(availableTypes.size()); return availableTypes.get(selectedIndex); } diff --git a/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java b/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java index 3938ae8fd..a52fb8457 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java @@ -24,7 +24,10 @@ import umc.cockple.demo.global.enums.Level; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.EnumMap; import java.util.List; +import java.util.Map; @Service @Transactional(readOnly = true) @@ -62,8 +65,22 @@ public GameRandomMatchResult match(Long memberId, Long gameBoardId) { throw new GameException(GameErrorCode.INSUFFICIENT_AVAILABLE_PLAYERS); } - GameMatchType matchType = matchTypeSelector.select(candidates); - List candidatePool = candidatePoolSelector.select(candidates, matchType); + List availableTypes = matchTypeSelector.findAvailableTypes(candidates); + List feasibleTypes = new ArrayList<>(); + Map> candidatePools = + new EnumMap<>(GameMatchType.class); + for (GameMatchType availableType : availableTypes) { + candidatePoolSelector.find(candidates, availableType).ifPresent(candidatePool -> { + feasibleTypes.add(availableType); + candidatePools.put(availableType, candidatePool); + }); + } + if (feasibleTypes.isEmpty()) { + throw new GameException(GameErrorCode.RANDOM_MATCH_NOT_FOUND); + } + + GameMatchType matchType = matchTypeSelector.selectFrom(feasibleTypes); + List candidatePool = candidatePools.get(matchType); List completedGames = gameRepository.findByGameBoardIdAndStatusInWithPlayers( gameBoard.getId(), COMPLETED_ONLY); GamePairHistory pairHistory = pairHistoryCalculator.calculate(completedGames); diff --git a/src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelectorTest.java b/src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelectorTest.java index 6a699582a..31611f5a8 100644 --- a/src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelectorTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/domain/service/matching/GameMatchTypeSelectorTest.java @@ -29,7 +29,10 @@ void select_returnsMixedDoubles() { member(1L, Gender.MALE), member(2L, Gender.MALE), member(3L, Gender.FEMALE), member(4L, Gender.FEMALE)); - assertThat(selector.select(candidates)).isEqualTo(GameMatchType.MIXED_DOUBLES); + List availableTypes = selector.findAvailableTypes(candidates); + + assertThat(availableTypes).containsExactly(GameMatchType.MIXED_DOUBLES); + assertThat(selector.selectFrom(availableTypes)).isEqualTo(GameMatchType.MIXED_DOUBLES); } @Test @@ -39,7 +42,10 @@ void select_returnsMenDoubles() { member(1L, Gender.MALE), member(2L, Gender.MALE), member(3L, Gender.MALE), member(4L, Gender.MALE)); - assertThat(selector.select(candidates)).isEqualTo(GameMatchType.MEN_DOUBLES); + List availableTypes = selector.findAvailableTypes(candidates); + + assertThat(availableTypes).containsExactly(GameMatchType.MEN_DOUBLES); + assertThat(selector.selectFrom(availableTypes)).isEqualTo(GameMatchType.MEN_DOUBLES); } @Test @@ -49,7 +55,10 @@ void select_returnsWomenDoubles() { member(1L, Gender.FEMALE), member(2L, Gender.FEMALE), member(3L, Gender.FEMALE), member(4L, Gender.FEMALE)); - assertThat(selector.select(candidates)).isEqualTo(GameMatchType.WOMEN_DOUBLES); + List availableTypes = selector.findAvailableTypes(candidates); + + assertThat(availableTypes).containsExactly(GameMatchType.WOMEN_DOUBLES); + assertThat(selector.selectFrom(availableTypes)).isEqualTo(GameMatchType.WOMEN_DOUBLES); } @Test @@ -59,7 +68,7 @@ void select_rejectsInsufficientGenderComposition() { member(1L, Gender.MALE), member(2L, Gender.MALE), member(3L, Gender.MALE), member(4L, Gender.FEMALE)); - assertThatThrownBy(() -> selector.select(candidates)) + assertThatThrownBy(() -> selector.findAvailableTypes(candidates)) .isInstanceOfSatisfying(GameException.class, exception -> assertThat(exception.getCode()) .isEqualTo(GameErrorCode.INSUFFICIENT_GENDER_COMPOSITION)); diff --git a/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java index 186eebba2..ce4b51523 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java @@ -31,6 +31,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -101,9 +102,12 @@ void match_orchestratesRandomMatching() { BOARD_ID, ACTIVE_STATUSES)).willReturn(activeGames); given(availabilityPolicy.filterAvailable( eq(members), eq(activeGames), any(LocalDateTime.class))).willReturn(members); - given(matchTypeSelector.select(candidates)).willReturn(GameMatchType.MEN_DOUBLES); - given(candidatePoolSelector.select(candidates, GameMatchType.MEN_DOUBLES)) - .willReturn(candidatePool); + given(matchTypeSelector.findAvailableTypes(candidates)) + .willReturn(List.of(GameMatchType.MEN_DOUBLES)); + given(candidatePoolSelector.find(candidates, GameMatchType.MEN_DOUBLES)) + .willReturn(Optional.of(candidatePool)); + given(matchTypeSelector.selectFrom(List.of(GameMatchType.MEN_DOUBLES))) + .willReturn(GameMatchType.MEN_DOUBLES); given(gameRepository.findByGameBoardIdAndStatusInWithPlayers( BOARD_ID, COMPLETED_ONLY)).willReturn(completedGames); given(pairHistoryCalculator.calculate(completedGames)).willReturn(pairHistory); @@ -117,9 +121,10 @@ void match_orchestratesRandomMatching() { then(gameBoardAccessValidator).should().validateGameHost(BOARD_ID, MEMBER_ID); then(availabilityPolicy).should().filterAvailable( eq(members), eq(activeGames), any(LocalDateTime.class)); - then(matchTypeSelector).should().select(candidates); + then(matchTypeSelector).should().findAvailableTypes(candidates); then(candidatePoolSelector).should() - .select(candidates, GameMatchType.MEN_DOUBLES); + .find(candidates, GameMatchType.MEN_DOUBLES); + then(matchTypeSelector).should().selectFrom(List.of(GameMatchType.MEN_DOUBLES)); then(pairHistoryCalculator).should().calculate(completedGames); then(bestMatchSelector).should() .select(candidatePool, GameMatchType.MEN_DOUBLES, pairHistory); @@ -150,7 +155,7 @@ void match_rejectsInsufficientAvailablePlayers() { assertThat(exception.getCode()) .isEqualTo(GameErrorCode.INSUFFICIENT_AVAILABLE_PLAYERS)); - then(matchTypeSelector).should(never()).select(anyList()); + then(matchTypeSelector).should(never()).findAvailableTypes(anyList()); then(candidatePoolSelector).shouldHaveNoInteractions(); then(pairHistoryCalculator).shouldHaveNoInteractions(); then(bestMatchSelector).shouldHaveNoInteractions(); @@ -158,16 +163,66 @@ void match_rejectsInsufficientAvailablePlayers() { BOARD_ID, COMPLETED_ONLY); } + @Test + @DisplayName("일부 타입만 경기 수 +5 안에서 구성 가능하면 해당 타입으로 매칭한다") + void match_selectsAmongTypesWithFeasibleCandidatePool() { + List members = List.of( + member(1L, Gender.MALE, Level.A, 0), + member(2L, Gender.MALE, Level.A, 0), + member(3L, Gender.MALE, Level.A, 0), + member(4L, Gender.MALE, Level.A, 0), + member(5L, Gender.FEMALE, Level.A, 6), + member(6L, Gender.FEMALE, Level.A, 6)); + List malePool = members.subList(0, 4); + List activeGames = List.of(); + List completedGames = List.of(); + GamePairHistory pairHistory = new GamePairHistoryCalculator().calculate(List.of()); + GameRandomMatchQueryService serviceWithRealSelectors = new GameRandomMatchQueryService( + gameBoardReader, + gameBoardMemberRepository, + gameRepository, + gameBoardAccessValidator, + availabilityPolicy, + new GameMatchTypeSelector(), + new GameCandidatePoolSelector(), + pairHistoryCalculator, + bestMatchSelector); + + given(gameBoardReader.read(BOARD_ID)).willReturn(board); + given(gameBoardMemberRepository.findByGameBoardIdOrderByIdAsc(BOARD_ID)) + .willReturn(members); + given(gameRepository.findByGameBoardIdAndStatusInWithPlayers( + BOARD_ID, ACTIVE_STATUSES)).willReturn(activeGames); + given(availabilityPolicy.filterAvailable( + eq(members), eq(activeGames), any(LocalDateTime.class))).willReturn(members); + given(gameRepository.findByGameBoardIdAndStatusInWithPlayers( + BOARD_ID, COMPLETED_ONLY)).willReturn(completedGames); + given(pairHistoryCalculator.calculate(completedGames)).willReturn(pairHistory); + given(bestMatchSelector.select( + malePool, GameMatchType.MEN_DOUBLES, pairHistory)) + .willReturn(List.of(1L, 2L, 3L, 4L)); + + GameRandomMatchResult result = serviceWithRealSelectors.match(MEMBER_ID, BOARD_ID); + + assertThat(result.gameBoardMemberIds()).containsExactly(1L, 2L, 3L, 4L); + then(bestMatchSelector).should() + .select(malePool, GameMatchType.MEN_DOUBLES, pairHistory); + } + private GameBoardMember member(Long id, Level level) { + return member(id, Gender.MALE, level, 0); + } + + private GameBoardMember member(Long id, Gender gender, Level level, int gameCount) { return GameBoardMember.builder() .id(id) .gameBoard(board) .name("선수" + id) - .gender(Gender.MALE) + .gender(gender) .level(level) .ageGroup(AgeGroup.TWENTIES) .participating(true) - .gameCount(0) + .gameCount(gameCount) .shuttlecockSubmitted(false) .build(); } From 4bcd98c2aaaa80650119e5536e36f2e61582857a Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Tue, 25 Aug 2026 06:59:09 +0900 Subject: [PATCH 12/14] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=9E=84=20?= =?UTF-8?q?=ED=8E=98=EC=96=B4=20=EC=9D=B4=EB=A0=A5=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EA=B8=B0=EC=99=80=20=EB=9E=9C=EB=8D=A4=20=EB=A7=A4=EC=B9=AD=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=AA=A8=EB=91=90=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=EC=BF=BC=EB=A6=AC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/domain/service/GamePairCount.java | 7 ++ .../service/GamePairHistoryCalculator.java | 18 ++++ .../game/repository/GameRepository.java | 2 +- .../game/repository/GameRepositoryCustom.java | 15 +++ .../repository/GameRepositoryCustomImpl.java | 86 +++++++++++++++++ .../query/GameDuplicateCheckQueryService.java | 14 +-- .../query/GameRandomMatchQueryService.java | 11 ++- .../GamePairHistoryCalculatorTest.java | 16 ++++ .../game/repository/GameRepositoryTest.java | 95 +++++++++++++++++++ .../GameDuplicateCheckQueryServiceTest.java | 32 +++---- .../GameRandomMatchQueryServiceTest.java | 25 ++--- 11 files changed, 279 insertions(+), 42 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/domain/game/domain/service/GamePairCount.java create mode 100644 src/main/java/umc/cockple/demo/domain/game/repository/GameRepositoryCustom.java create mode 100644 src/main/java/umc/cockple/demo/domain/game/repository/GameRepositoryCustomImpl.java create mode 100644 src/test/java/umc/cockple/demo/domain/game/repository/GameRepositoryTest.java diff --git a/src/main/java/umc/cockple/demo/domain/game/domain/service/GamePairCount.java b/src/main/java/umc/cockple/demo/domain/game/domain/service/GamePairCount.java new file mode 100644 index 000000000..e9924cd0b --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/domain/service/GamePairCount.java @@ -0,0 +1,7 @@ +package umc.cockple.demo.domain.game.domain.service; + +public record GamePairCount( + Long memberIdA, + Long memberIdB, + long count) { +} diff --git a/src/main/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculator.java b/src/main/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculator.java index 0ee83b7d6..cb54069da 100644 --- a/src/main/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculator.java +++ b/src/main/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculator.java @@ -31,6 +31,20 @@ public GamePairHistory calculate(List completedGames) { return new GamePairHistory(Map.copyOf(completedGameCounts), lastGamePairs); } + public GamePairHistory fromCounts( + List pairCounts, + List lastGameMemberIds) { + Map completedGameCounts = new HashMap<>(); + pairCounts.forEach(pairCount -> completedGameCounts.put( + MemberPair.of(pairCount.memberIdA(), pairCount.memberIdB()), + Math.toIntExact(pairCount.count()))); + + List lastGamePairs = pairsOf(lastGameMemberIds); + return new GamePairHistory( + Map.copyOf(completedGameCounts), + Set.copyOf(lastGamePairs)); + } + private List pairsOf(Game game) { List memberIds = game.getPlayers().stream() .map(GamePlayer::getGameBoardMember) @@ -39,6 +53,10 @@ private List pairsOf(Game game) { .sorted() .toList(); + return pairsOf(memberIds); + } + + private List pairsOf(List memberIds) { List pairs = new ArrayList<>(); for (int i = 0; i < memberIds.size(); i++) { for (int j = i + 1; j < memberIds.size(); j++) { diff --git a/src/main/java/umc/cockple/demo/domain/game/repository/GameRepository.java b/src/main/java/umc/cockple/demo/domain/game/repository/GameRepository.java index 7e1a5371a..2b85c4d87 100644 --- a/src/main/java/umc/cockple/demo/domain/game/repository/GameRepository.java +++ b/src/main/java/umc/cockple/demo/domain/game/repository/GameRepository.java @@ -12,7 +12,7 @@ import java.util.List; import java.util.Optional; -public interface GameRepository extends JpaRepository { +public interface GameRepository extends JpaRepository, GameRepositoryCustom { Optional findByCourtIdAndStatus(Long courtId, GameStatus status); diff --git a/src/main/java/umc/cockple/demo/domain/game/repository/GameRepositoryCustom.java b/src/main/java/umc/cockple/demo/domain/game/repository/GameRepositoryCustom.java new file mode 100644 index 000000000..63bfa73fa --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/repository/GameRepositoryCustom.java @@ -0,0 +1,15 @@ +package umc.cockple.demo.domain.game.repository; + +import umc.cockple.demo.domain.game.domain.service.GamePairCount; + +import java.util.Collection; +import java.util.List; + +public interface GameRepositoryCustom { + + List countCompletedGamePairs( + Long gameBoardId, + Collection gameBoardMemberIds); + + List findLatestCompletedGameMemberIds(Long gameBoardId); +} diff --git a/src/main/java/umc/cockple/demo/domain/game/repository/GameRepositoryCustomImpl.java b/src/main/java/umc/cockple/demo/domain/game/repository/GameRepositoryCustomImpl.java new file mode 100644 index 000000000..65d58d9b9 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/repository/GameRepositoryCustomImpl.java @@ -0,0 +1,86 @@ +package umc.cockple.demo.domain.game.repository; + +import com.querydsl.core.Tuple; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import umc.cockple.demo.domain.game.domain.QGame; +import umc.cockple.demo.domain.game.domain.QGamePlayer; +import umc.cockple.demo.domain.game.domain.service.GamePairCount; +import umc.cockple.demo.domain.game.enums.GameStatus; + +import java.util.Collection; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class GameRepositoryCustomImpl implements GameRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List countCompletedGamePairs( + Long gameBoardId, + Collection gameBoardMemberIds) { + if (gameBoardMemberIds.size() < 2) { + return List.of(); + } + + QGame game = QGame.game; + QGamePlayer firstPlayer = new QGamePlayer("firstPlayer"); + QGamePlayer secondPlayer = new QGamePlayer("secondPlayer"); + NumberExpression completedGameCount = game.id.countDistinct(); + + List rows = queryFactory + .select( + firstPlayer.gameBoardMember.id, + secondPlayer.gameBoardMember.id, + completedGameCount) + .from(game) + .join(game.players, firstPlayer) + .join(game.players, secondPlayer) + .where( + game.gameBoard.id.eq(gameBoardId), + game.status.eq(GameStatus.COMPLETED), + firstPlayer.gameBoardMember.id.in(gameBoardMemberIds), + secondPlayer.gameBoardMember.id.in(gameBoardMemberIds), + firstPlayer.gameBoardMember.id.lt(secondPlayer.gameBoardMember.id)) + .groupBy( + firstPlayer.gameBoardMember.id, + secondPlayer.gameBoardMember.id) + .fetch(); + + return rows.stream() + .map(row -> new GamePairCount( + row.get(firstPlayer.gameBoardMember.id), + row.get(secondPlayer.gameBoardMember.id), + row.get(completedGameCount))) + .toList(); + } + + @Override + public List findLatestCompletedGameMemberIds(Long gameBoardId) { + QGame game = QGame.game; + QGamePlayer gamePlayer = QGamePlayer.gamePlayer; + + Long latestCompletedGameId = queryFactory + .select(game.id) + .from(game) + .where( + game.gameBoard.id.eq(gameBoardId), + game.status.eq(GameStatus.COMPLETED)) + .orderBy(game.completedAt.desc(), game.id.desc()) + .fetchFirst(); + if (latestCompletedGameId == null) { + return List.of(); + } + + return queryFactory + .select(gamePlayer.gameBoardMember.id) + .from(gamePlayer) + .where(gamePlayer.game.id.eq(latestCompletedGameId)) + .orderBy(gamePlayer.gameBoardMember.id.asc()) + .fetch(); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java b/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java index d89f8717e..bd965207e 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java @@ -3,9 +3,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import umc.cockple.demo.domain.game.domain.Game; import umc.cockple.demo.domain.game.domain.GameBoard; -import umc.cockple.demo.domain.game.enums.GameStatus; +import umc.cockple.demo.domain.game.domain.service.GamePairCount; import umc.cockple.demo.domain.game.exception.GameErrorCode; import umc.cockple.demo.domain.game.exception.GameException; import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; @@ -26,8 +25,6 @@ @RequiredArgsConstructor public class GameDuplicateCheckQueryService { - private static final List COMPLETED_ONLY = List.of(GameStatus.COMPLETED); - private final GameBoardReader gameBoardReader; private final GameRepository gameRepository; private final GameBoardMemberRepository gameBoardMemberRepository; @@ -42,9 +39,12 @@ public GameDuplicateCheckResult checkDuplicates(Long memberId, Long gameBoardId, List targetMemberIds = gameBoardMemberIds.stream().distinct().toList(); validateMembersBelongToBoard(gameBoard.getId(), targetMemberIds); - List completedGames = gameRepository - .findByGameBoardIdAndStatusInWithPlayers(gameBoard.getId(), COMPLETED_ONLY); - GamePairHistory pairHistory = gamePairHistoryCalculator.calculate(completedGames); + List pairCounts = gameRepository.countCompletedGamePairs( + gameBoard.getId(), targetMemberIds); + List lastGameMemberIds = gameRepository + .findLatestCompletedGameMemberIds(gameBoard.getId()); + GamePairHistory pairHistory = gamePairHistoryCalculator.fromCounts( + pairCounts, lastGameMemberIds); List pairs = new ArrayList<>(); for (int i = 0; i < targetMemberIds.size(); i++) { diff --git a/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java b/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java index a52fb8457..749cf320d 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java @@ -6,6 +6,7 @@ import umc.cockple.demo.domain.game.domain.Game; import umc.cockple.demo.domain.game.domain.GameBoard; import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.domain.service.GamePairCount; import umc.cockple.demo.domain.game.enums.GameMatchType; import umc.cockple.demo.domain.game.enums.GameStatus; import umc.cockple.demo.domain.game.exception.GameErrorCode; @@ -36,7 +37,6 @@ public class GameRandomMatchQueryService { private static final List ACTIVE_STATUSES = List.of(GameStatus.WAITING, GameStatus.PLAYING); - private static final List COMPLETED_ONLY = List.of(GameStatus.COMPLETED); private final GameBoardReader gameBoardReader; private final GameBoardMemberRepository gameBoardMemberRepository; @@ -81,9 +81,12 @@ public GameRandomMatchResult match(Long memberId, Long gameBoardId) { GameMatchType matchType = matchTypeSelector.selectFrom(feasibleTypes); List candidatePool = candidatePools.get(matchType); - List completedGames = gameRepository.findByGameBoardIdAndStatusInWithPlayers( - gameBoard.getId(), COMPLETED_ONLY); - GamePairHistory pairHistory = pairHistoryCalculator.calculate(completedGames); + List candidatePoolIds = candidatePool.stream() + .map(GameBoardMember::getId) + .toList(); + List pairCounts = gameRepository.countCompletedGamePairs( + gameBoard.getId(), candidatePoolIds); + GamePairHistory pairHistory = pairHistoryCalculator.fromCounts(pairCounts, List.of()); List matchedMemberIds = bestMatchSelector.select( candidatePool, matchType, pairHistory); diff --git a/src/test/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculatorTest.java b/src/test/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculatorTest.java index ebc064af8..d9bd23f9a 100644 --- a/src/test/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculatorTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/domain/service/GamePairHistoryCalculatorTest.java @@ -78,4 +78,20 @@ void calculate_returnsEmptyHistoryWithoutCompletedGames() { assertThat(history.count(1L, 2L)).isZero(); assertThat(history.playedInLastGame(1L, 2L)).isFalse(); } + + @Test + @DisplayName("집계된 쌍별 횟수와 직전 경기 멤버 ID로 이력을 복원한다") + void fromCounts_restoresPairHistory() { + GamePairHistory history = calculator.fromCounts( + List.of( + new GamePairCount(1L, 2L, 3), + new GamePairCount(1L, 3L, 1)), + List.of(1L, 2L, 4L, 5L)); + + assertThat(history.count(2L, 1L)).isEqualTo(3); + assertThat(history.count(1L, 3L)).isEqualTo(1); + assertThat(history.count(2L, 3L)).isZero(); + assertThat(history.playedInLastGame(1L, 2L)).isTrue(); + assertThat(history.playedInLastGame(1L, 3L)).isFalse(); + } } diff --git a/src/test/java/umc/cockple/demo/domain/game/repository/GameRepositoryTest.java b/src/test/java/umc/cockple/demo/domain/game/repository/GameRepositoryTest.java new file mode 100644 index 000000000..c499fe11b --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/game/repository/GameRepositoryTest.java @@ -0,0 +1,95 @@ +package umc.cockple.demo.domain.game.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import umc.cockple.demo.domain.game.domain.Game; +import umc.cockple.demo.domain.game.domain.GameBoard; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.domain.GamePlayer; +import umc.cockple.demo.domain.game.domain.service.GamePairCount; +import umc.cockple.demo.domain.game.enums.AgeGroup; +import umc.cockple.demo.domain.game.enums.GameStatus; +import umc.cockple.demo.global.config.QuerydslConfig; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import(QuerydslConfig.class) +@DisplayName("GameRepository") +class GameRepositoryTest { + + @Autowired private GameRepository gameRepository; + @Autowired private TestEntityManager entityManager; + + @Test + @DisplayName("완료 경기의 대상 멤버 페어 횟수와 최근 완료 경기 멤버만 조회한다") + void completedPairQueries_returnAggregatesAndLatestGameMembers() { + GameBoard board = entityManager.persist(GameBoard.create()); + List members = List.of( + persistMember(board, "선수1"), + persistMember(board, "선수2"), + persistMember(board, "선수3"), + persistMember(board, "선수4"), + persistMember(board, "선수5"), + persistMember(board, "선수6")); + persistGame(board, GameStatus.COMPLETED, + LocalDateTime.of(2026, 8, 25, 10, 0), members.subList(0, 4)); + persistGame(board, GameStatus.COMPLETED, + LocalDateTime.of(2026, 8, 25, 11, 0), + List.of(members.get(0), members.get(1), members.get(4), members.get(5))); + persistGame(board, GameStatus.PLAYING, null, members.subList(0, 4)); + entityManager.flush(); + entityManager.clear(); + + List targetIds = members.subList(0, 4).stream() + .map(GameBoardMember::getId) + .toList(); + List pairCounts = gameRepository.countCompletedGamePairs( + board.getId(), targetIds); + List latestMemberIds = gameRepository.findLatestCompletedGameMemberIds(board.getId()); + + assertThat(pairCounts).hasSize(6); + assertThat(pairCounts).anySatisfy(pairCount -> { + assertThat(pairCount.memberIdA()).isEqualTo(members.get(0).getId()); + assertThat(pairCount.memberIdB()).isEqualTo(members.get(1).getId()); + assertThat(pairCount.count()).isEqualTo(2); + }); + assertThat(latestMemberIds).containsExactly( + members.get(0).getId(), + members.get(1).getId(), + members.get(4).getId(), + members.get(5).getId()); + } + + private GameBoardMember persistMember(GameBoard board, String name) { + GameBoardMember member = GameBoardMember.create( + name, Gender.MALE, Level.A, AgeGroup.TWENTIES); + board.addGameBoardMember(member); + return entityManager.persist(member); + } + + private void persistGame( + GameBoard board, + GameStatus status, + LocalDateTime completedAt, + List members) { + Game game = Game.builder() + .gameBoard(board) + .status(status) + .completedAt(completedAt) + .build(); + for (int index = 0; index < members.size(); index++) { + game.addPlayer(GamePlayer.create(members.get(index), index)); + } + entityManager.persist(game); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java index 69090f912..64108f690 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java @@ -6,8 +6,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import umc.cockple.demo.domain.game.domain.GameBoard; import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.domain.GameBoard; +import umc.cockple.demo.domain.game.domain.service.GamePairCount; import umc.cockple.demo.domain.game.exception.GameErrorCode; import umc.cockple.demo.domain.game.exception.GameException; import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; @@ -19,13 +20,10 @@ import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.support.fixture.GameFixture; -import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @ExtendWith(MockitoExtension.class) @@ -55,27 +53,24 @@ void setUp() { @Test @DisplayName("선택 멤버의 쌍별로 함께 완료한 게임 수와 직전 게임 동반 여부를 계산한다") void checkDuplicates_computesPairCountsAndLastGame() { - // given - 멤버 7,8,9,10,11,12 + // given - 멤버 7,8,9,10 GameBoardMember m7 = GameFixture.member(7L, board, "7", Level.A); GameBoardMember m8 = GameFixture.member(8L, board, "8", Level.A); GameBoardMember m9 = GameFixture.member(9L, board, "9", Level.A); GameBoardMember m10 = GameFixture.member(10L, board, "10", Level.A); - GameBoardMember m11 = GameFixture.member(11L, board, "11", Level.A); - GameBoardMember m12 = GameFixture.member(12L, board, "12", Level.A); - - // 완료 게임 1(먼저): {7,8,9,10} - var earlier = GameFixture.completedGame(50L, board, LocalDateTime.now().minusMinutes(10), - GameFixture.player(m7, 0), GameFixture.player(m8, 1), GameFixture.player(m9, 2), GameFixture.player(m10, 3)); - // 완료 게임 2(직전=가장 최근): {7,8,11,12} - var last = GameFixture.completedGame(51L, board, LocalDateTime.now().minusMinutes(1), - GameFixture.player(m7, 0), GameFixture.player(m8, 1), GameFixture.player(m11, 2), GameFixture.player(m12, 3)); - List selected = List.of(7L, 8L, 9L, 10L); given(gameBoardReader.read(BOARD_ID)).willReturn(board); given(gameBoardMemberRepository.findByGameBoardIdAndIdIn(BOARD_ID, selected)) .willReturn(List.of(m7, m8, m9, m10)); - given(gameRepository.findByGameBoardIdAndStatusInWithPlayers(eq(BOARD_ID), anyList())) - .willReturn(List.of(earlier, last)); + given(gameRepository.countCompletedGamePairs(BOARD_ID, selected)).willReturn(List.of( + new GamePairCount(7L, 8L, 2), + new GamePairCount(7L, 9L, 1), + new GamePairCount(7L, 10L, 1), + new GamePairCount(8L, 9L, 1), + new GamePairCount(8L, 10L, 1), + new GamePairCount(9L, 10L, 1))); + given(gameRepository.findLatestCompletedGameMemberIds(BOARD_ID)) + .willReturn(List.of(7L, 8L, 11L, 12L)); // when GameDuplicateCheckResult result = gameDuplicateCheckQueryService.checkDuplicates(MEMBER_ID, BOARD_ID, selected); @@ -104,7 +99,8 @@ void checkDuplicates_noCompletedGames() { List selected = List.of(7L, 8L); given(gameBoardReader.read(BOARD_ID)).willReturn(board); given(gameBoardMemberRepository.findByGameBoardIdAndIdIn(BOARD_ID, selected)).willReturn(List.of(m7, m8)); - given(gameRepository.findByGameBoardIdAndStatusInWithPlayers(eq(BOARD_ID), anyList())).willReturn(List.of()); + given(gameRepository.countCompletedGamePairs(BOARD_ID, selected)).willReturn(List.of()); + given(gameRepository.findLatestCompletedGameMemberIds(BOARD_ID)).willReturn(List.of()); GameDuplicateCheckResult result = gameDuplicateCheckQueryService.checkDuplicates(MEMBER_ID, BOARD_ID, selected); diff --git a/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java index ce4b51523..e8080187c 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java @@ -9,6 +9,7 @@ import umc.cockple.demo.domain.game.domain.Game; import umc.cockple.demo.domain.game.domain.GameBoard; import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.domain.service.GamePairCount; import umc.cockple.demo.domain.game.enums.AgeGroup; import umc.cockple.demo.domain.game.enums.GameMatchType; import umc.cockple.demo.domain.game.enums.GameStatus; @@ -50,7 +51,6 @@ class GameRandomMatchQueryServiceTest { private static final Long BOARD_ID = 1L; private static final List ACTIVE_STATUSES = List.of(GameStatus.WAITING, GameStatus.PLAYING); - private static final List COMPLETED_ONLY = List.of(GameStatus.COMPLETED); @Mock private GameBoardReader gameBoardReader; @Mock private GameBoardMemberRepository gameBoardMemberRepository; @@ -92,7 +92,7 @@ void match_orchestratesRandomMatching() { List candidates = members.subList(1, 5); List candidatePool = List.copyOf(candidates); List activeGames = List.of(); - List completedGames = List.of(); + List pairCounts = List.of(); GamePairHistory pairHistory = new GamePairHistoryCalculator().calculate(List.of()); given(gameBoardReader.read(BOARD_ID)).willReturn(board); @@ -108,9 +108,9 @@ void match_orchestratesRandomMatching() { .willReturn(Optional.of(candidatePool)); given(matchTypeSelector.selectFrom(List.of(GameMatchType.MEN_DOUBLES))) .willReturn(GameMatchType.MEN_DOUBLES); - given(gameRepository.findByGameBoardIdAndStatusInWithPlayers( - BOARD_ID, COMPLETED_ONLY)).willReturn(completedGames); - given(pairHistoryCalculator.calculate(completedGames)).willReturn(pairHistory); + given(gameRepository.countCompletedGamePairs( + BOARD_ID, List.of(2L, 3L, 4L, 5L))).willReturn(pairCounts); + given(pairHistoryCalculator.fromCounts(pairCounts, List.of())).willReturn(pairHistory); given(bestMatchSelector.select( candidatePool, GameMatchType.MEN_DOUBLES, pairHistory)) .willReturn(List.of(2L, 3L, 4L, 5L)); @@ -125,7 +125,9 @@ void match_orchestratesRandomMatching() { then(candidatePoolSelector).should() .find(candidates, GameMatchType.MEN_DOUBLES); then(matchTypeSelector).should().selectFrom(List.of(GameMatchType.MEN_DOUBLES)); - then(pairHistoryCalculator).should().calculate(completedGames); + then(gameRepository).should().countCompletedGamePairs( + BOARD_ID, List.of(2L, 3L, 4L, 5L)); + then(pairHistoryCalculator).should().fromCounts(pairCounts, List.of()); then(bestMatchSelector).should() .select(candidatePool, GameMatchType.MEN_DOUBLES, pairHistory); then(gameRepository).should(never()).save(any(Game.class)); @@ -159,8 +161,7 @@ void match_rejectsInsufficientAvailablePlayers() { then(candidatePoolSelector).shouldHaveNoInteractions(); then(pairHistoryCalculator).shouldHaveNoInteractions(); then(bestMatchSelector).shouldHaveNoInteractions(); - then(gameRepository).should(never()).findByGameBoardIdAndStatusInWithPlayers( - BOARD_ID, COMPLETED_ONLY); + then(gameRepository).should(never()).countCompletedGamePairs(eq(BOARD_ID), anyList()); } @Test @@ -175,7 +176,7 @@ void match_selectsAmongTypesWithFeasibleCandidatePool() { member(6L, Gender.FEMALE, Level.A, 6)); List malePool = members.subList(0, 4); List activeGames = List.of(); - List completedGames = List.of(); + List pairCounts = List.of(); GamePairHistory pairHistory = new GamePairHistoryCalculator().calculate(List.of()); GameRandomMatchQueryService serviceWithRealSelectors = new GameRandomMatchQueryService( gameBoardReader, @@ -195,9 +196,9 @@ void match_selectsAmongTypesWithFeasibleCandidatePool() { BOARD_ID, ACTIVE_STATUSES)).willReturn(activeGames); given(availabilityPolicy.filterAvailable( eq(members), eq(activeGames), any(LocalDateTime.class))).willReturn(members); - given(gameRepository.findByGameBoardIdAndStatusInWithPlayers( - BOARD_ID, COMPLETED_ONLY)).willReturn(completedGames); - given(pairHistoryCalculator.calculate(completedGames)).willReturn(pairHistory); + given(gameRepository.countCompletedGamePairs( + BOARD_ID, List.of(1L, 2L, 3L, 4L))).willReturn(pairCounts); + given(pairHistoryCalculator.fromCounts(pairCounts, List.of())).willReturn(pairHistory); given(bestMatchSelector.select( malePool, GameMatchType.MEN_DOUBLES, pairHistory)) .willReturn(List.of(1L, 2L, 3L, 4L)); From 0ecbf4e9a31135a69534744f7e1a852fb14a8b5f Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Tue, 25 Aug 2026 07:02:43 +0900 Subject: [PATCH 13/14] =?UTF-8?q?refactor:=20=EB=A6=AC=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=A7=81=EC=A0=91=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=EC=9D=84=20=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20Reader?= =?UTF-8?q?=EB=A5=BC=20=EA=B2=BD=EC=9C=A0=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/GameDuplicateCheckQueryService.java | 18 +++++---- .../query/GameRandomMatchQueryService.java | 16 ++++---- .../support/reader/GameBoardMemberReader.java | 12 ++++++ .../service/support/reader/GameReader.java | 11 ++++++ .../GameDuplicateCheckQueryServiceTest.java | 28 +++++++------- .../GameRandomMatchQueryServiceTest.java | 38 +++++++++---------- .../reader/GameBoardMemberReaderTest.java | 23 +++++++++++ .../support/reader/GameReaderTest.java | 24 ++++++++++++ 8 files changed, 120 insertions(+), 50 deletions(-) diff --git a/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java b/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java index bd965207e..c9e442211 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryService.java @@ -7,12 +7,12 @@ import umc.cockple.demo.domain.game.domain.service.GamePairCount; import umc.cockple.demo.domain.game.exception.GameErrorCode; import umc.cockple.demo.domain.game.exception.GameException; -import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; -import umc.cockple.demo.domain.game.repository.GameRepository; import umc.cockple.demo.domain.game.service.query.result.GameDuplicateCheckResult; import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator; import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator.GamePairHistory; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; +import umc.cockple.demo.domain.game.service.support.reader.GameBoardMemberReader; +import umc.cockple.demo.domain.game.service.support.reader.GameReader; import java.util.ArrayList; import java.util.List; @@ -26,8 +26,8 @@ public class GameDuplicateCheckQueryService { private final GameBoardReader gameBoardReader; - private final GameRepository gameRepository; - private final GameBoardMemberRepository gameBoardMemberRepository; + private final GameReader gameReader; + private final GameBoardMemberReader gameBoardMemberReader; private final GamePairHistoryCalculator gamePairHistoryCalculator; /** @@ -39,10 +39,10 @@ public GameDuplicateCheckResult checkDuplicates(Long memberId, Long gameBoardId, List targetMemberIds = gameBoardMemberIds.stream().distinct().toList(); validateMembersBelongToBoard(gameBoard.getId(), targetMemberIds); - List pairCounts = gameRepository.countCompletedGamePairs( + List pairCounts = gameReader.readCompletedPairCounts( gameBoard.getId(), targetMemberIds); - List lastGameMemberIds = gameRepository - .findLatestCompletedGameMemberIds(gameBoard.getId()); + List lastGameMemberIds = gameReader + .readLatestCompletedGameMemberIds(gameBoard.getId()); GamePairHistory pairHistory = gamePairHistoryCalculator.fromCounts( pairCounts, lastGameMemberIds); @@ -60,7 +60,9 @@ public GameDuplicateCheckResult checkDuplicates(Long memberId, Long gameBoardId, } private void validateMembersBelongToBoard(Long gameBoardId, List memberIds) { - long foundCount = gameBoardMemberRepository.findByGameBoardIdAndIdIn(gameBoardId, memberIds).size(); + long foundCount = gameBoardMemberReader + .readAllByGameBoardAndIds(gameBoardId, memberIds) + .size(); if (foundCount != memberIds.size()) { throw new GameException(GameErrorCode.GAME_BOARD_MEMBER_NOT_FOUND); } diff --git a/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java b/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java index 749cf320d..3bfcb21d5 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryService.java @@ -11,12 +11,12 @@ import umc.cockple.demo.domain.game.enums.GameStatus; import umc.cockple.demo.domain.game.exception.GameErrorCode; import umc.cockple.demo.domain.game.exception.GameException; -import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; -import umc.cockple.demo.domain.game.repository.GameRepository; import umc.cockple.demo.domain.game.service.query.result.GameRandomMatchResult; import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator; import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator.GamePairHistory; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; +import umc.cockple.demo.domain.game.service.support.reader.GameBoardMemberReader; +import umc.cockple.demo.domain.game.service.support.reader.GameReader; import umc.cockple.demo.domain.game.domain.service.matching.GameBestMatchSelector; import umc.cockple.demo.domain.game.domain.service.matching.GameCandidatePoolSelector; import umc.cockple.demo.domain.game.domain.service.matching.GameMatchTypeSelector; @@ -39,8 +39,8 @@ public class GameRandomMatchQueryService { List.of(GameStatus.WAITING, GameStatus.PLAYING); private final GameBoardReader gameBoardReader; - private final GameBoardMemberRepository gameBoardMemberRepository; - private final GameRepository gameRepository; + private final GameBoardMemberReader gameBoardMemberReader; + private final GameReader gameReader; private final GameBoardAccessValidator gameBoardAccessValidator; private final GameBoardMemberAvailabilityPolicy availabilityPolicy; private final GameMatchTypeSelector matchTypeSelector; @@ -53,10 +53,8 @@ public GameRandomMatchResult match(Long memberId, Long gameBoardId) { GameBoard gameBoard = gameBoardReader.read(gameBoardId); LocalDateTime now = LocalDateTime.now(); - List members = gameBoardMemberRepository - .findByGameBoardIdOrderByIdAsc(gameBoard.getId()); - List activeGames = gameRepository.findByGameBoardIdAndStatusInWithPlayers( - gameBoard.getId(), ACTIVE_STATUSES); + List members = gameBoardMemberReader.readAllByGameBoard(gameBoard.getId()); + List activeGames = gameReader.readAllByGameBoardAndStatuses(gameBoard.getId(), ACTIVE_STATUSES); List candidates = availabilityPolicy .filterAvailable(members, activeGames, now).stream() .filter(member -> member.getLevel() != Level.NONE) @@ -84,7 +82,7 @@ public GameRandomMatchResult match(Long memberId, Long gameBoardId) { List candidatePoolIds = candidatePool.stream() .map(GameBoardMember::getId) .toList(); - List pairCounts = gameRepository.countCompletedGamePairs( + List pairCounts = gameReader.readCompletedPairCounts( gameBoard.getId(), candidatePoolIds); GamePairHistory pairHistory = pairHistoryCalculator.fromCounts(pairCounts, List.of()); List matchedMemberIds = bestMatchSelector.select( diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/reader/GameBoardMemberReader.java b/src/main/java/umc/cockple/demo/domain/game/service/support/reader/GameBoardMemberReader.java index 4bbc7e1d5..02abffe27 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/support/reader/GameBoardMemberReader.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/support/reader/GameBoardMemberReader.java @@ -10,6 +10,7 @@ import umc.cockple.demo.global.enums.Gender; import umc.cockple.demo.global.enums.Level; +import java.util.Collection; import java.util.List; @Service @@ -28,6 +29,17 @@ public long countByGameBoard(Long gameBoardId) { return gameBoardMemberRepository.countByGameBoardId(gameBoardId); } + public List readAllByGameBoard(Long gameBoardId) { + return gameBoardMemberRepository.findByGameBoardIdOrderByIdAsc(gameBoardId); + } + + public List readAllByGameBoardAndIds( + Long gameBoardId, + Collection gameBoardMemberIds) { + return gameBoardMemberRepository.findByGameBoardIdAndIdIn( + gameBoardId, gameBoardMemberIds); + } + public List readAllByFilters( Long gameBoardId, List levels, diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/reader/GameReader.java b/src/main/java/umc/cockple/demo/domain/game/service/support/reader/GameReader.java index b41b9583d..24e580db5 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/support/reader/GameReader.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/support/reader/GameReader.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import umc.cockple.demo.domain.game.domain.Game; +import umc.cockple.demo.domain.game.domain.service.GamePairCount; import umc.cockple.demo.domain.game.enums.GameStatus; import umc.cockple.demo.domain.game.repository.GameRepository; @@ -26,4 +27,14 @@ public boolean existsByGameBoardMemberAndStatuses( Long gameBoardMemberId, Collection statuses) { return gameRepository.existsByGameBoardMemberIdAndStatusIn(gameBoardMemberId, statuses); } + + public List readCompletedPairCounts( + Long gameBoardId, + Collection gameBoardMemberIds) { + return gameRepository.countCompletedGamePairs(gameBoardId, gameBoardMemberIds); + } + + public List readLatestCompletedGameMemberIds(Long gameBoardId) { + return gameRepository.findLatestCompletedGameMemberIds(gameBoardId); + } } diff --git a/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java index 64108f690..cea90810c 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/query/GameDuplicateCheckQueryServiceTest.java @@ -11,12 +11,12 @@ import umc.cockple.demo.domain.game.domain.service.GamePairCount; import umc.cockple.demo.domain.game.exception.GameErrorCode; import umc.cockple.demo.domain.game.exception.GameException; -import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; -import umc.cockple.demo.domain.game.repository.GameRepository; import umc.cockple.demo.domain.game.service.query.result.GameDuplicateCheckResult; import umc.cockple.demo.domain.game.service.query.result.GameDuplicateCheckResult.PairView; import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; +import umc.cockple.demo.domain.game.service.support.reader.GameBoardMemberReader; +import umc.cockple.demo.domain.game.service.support.reader.GameReader; import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.support.fixture.GameFixture; @@ -31,8 +31,8 @@ class GameDuplicateCheckQueryServiceTest { @Mock private GameBoardReader gameBoardReader; - @Mock private GameRepository gameRepository; - @Mock private GameBoardMemberRepository gameBoardMemberRepository; + @Mock private GameReader gameReader; + @Mock private GameBoardMemberReader gameBoardMemberReader; private GameDuplicateCheckQueryService gameDuplicateCheckQueryService; @@ -45,8 +45,8 @@ void setUp() { board = GameFixture.gameBoard(BOARD_ID); gameDuplicateCheckQueryService = new GameDuplicateCheckQueryService( gameBoardReader, - gameRepository, - gameBoardMemberRepository, + gameReader, + gameBoardMemberReader, new GamePairHistoryCalculator()); } @@ -60,16 +60,16 @@ void checkDuplicates_computesPairCountsAndLastGame() { GameBoardMember m10 = GameFixture.member(10L, board, "10", Level.A); List selected = List.of(7L, 8L, 9L, 10L); given(gameBoardReader.read(BOARD_ID)).willReturn(board); - given(gameBoardMemberRepository.findByGameBoardIdAndIdIn(BOARD_ID, selected)) + given(gameBoardMemberReader.readAllByGameBoardAndIds(BOARD_ID, selected)) .willReturn(List.of(m7, m8, m9, m10)); - given(gameRepository.countCompletedGamePairs(BOARD_ID, selected)).willReturn(List.of( + given(gameReader.readCompletedPairCounts(BOARD_ID, selected)).willReturn(List.of( new GamePairCount(7L, 8L, 2), new GamePairCount(7L, 9L, 1), new GamePairCount(7L, 10L, 1), new GamePairCount(8L, 9L, 1), new GamePairCount(8L, 10L, 1), new GamePairCount(9L, 10L, 1))); - given(gameRepository.findLatestCompletedGameMemberIds(BOARD_ID)) + given(gameReader.readLatestCompletedGameMemberIds(BOARD_ID)) .willReturn(List.of(7L, 8L, 11L, 12L)); // when @@ -98,9 +98,10 @@ void checkDuplicates_noCompletedGames() { GameBoardMember m8 = GameFixture.member(8L, board, "8", Level.A); List selected = List.of(7L, 8L); given(gameBoardReader.read(BOARD_ID)).willReturn(board); - given(gameBoardMemberRepository.findByGameBoardIdAndIdIn(BOARD_ID, selected)).willReturn(List.of(m7, m8)); - given(gameRepository.countCompletedGamePairs(BOARD_ID, selected)).willReturn(List.of()); - given(gameRepository.findLatestCompletedGameMemberIds(BOARD_ID)).willReturn(List.of()); + given(gameBoardMemberReader.readAllByGameBoardAndIds(BOARD_ID, selected)) + .willReturn(List.of(m7, m8)); + given(gameReader.readCompletedPairCounts(BOARD_ID, selected)).willReturn(List.of()); + given(gameReader.readLatestCompletedGameMemberIds(BOARD_ID)).willReturn(List.of()); GameDuplicateCheckResult result = gameDuplicateCheckQueryService.checkDuplicates(MEMBER_ID, BOARD_ID, selected); @@ -115,7 +116,8 @@ void checkDuplicates_memberNotOnBoard() { GameBoardMember m7 = GameFixture.member(7L, board, "7", Level.A); List selected = List.of(7L, 999L); given(gameBoardReader.read(BOARD_ID)).willReturn(board); - given(gameBoardMemberRepository.findByGameBoardIdAndIdIn(BOARD_ID, selected)).willReturn(List.of(m7)); + given(gameBoardMemberReader.readAllByGameBoardAndIds(BOARD_ID, selected)) + .willReturn(List.of(m7)); assertThatThrownBy(() -> gameDuplicateCheckQueryService.checkDuplicates(MEMBER_ID, BOARD_ID, selected)) .isInstanceOf(GameException.class) diff --git a/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java index e8080187c..382253f58 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/query/GameRandomMatchQueryServiceTest.java @@ -15,12 +15,12 @@ import umc.cockple.demo.domain.game.enums.GameStatus; import umc.cockple.demo.domain.game.exception.GameErrorCode; import umc.cockple.demo.domain.game.exception.GameException; -import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; -import umc.cockple.demo.domain.game.repository.GameRepository; import umc.cockple.demo.domain.game.service.query.result.GameRandomMatchResult; import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator; import umc.cockple.demo.domain.game.domain.service.GamePairHistoryCalculator.GamePairHistory; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; +import umc.cockple.demo.domain.game.service.support.reader.GameBoardMemberReader; +import umc.cockple.demo.domain.game.service.support.reader.GameReader; import umc.cockple.demo.domain.game.domain.service.matching.GameBestMatchSelector; import umc.cockple.demo.domain.game.domain.service.matching.GameCandidatePoolSelector; import umc.cockple.demo.domain.game.domain.service.matching.GameMatchTypeSelector; @@ -53,8 +53,8 @@ class GameRandomMatchQueryServiceTest { List.of(GameStatus.WAITING, GameStatus.PLAYING); @Mock private GameBoardReader gameBoardReader; - @Mock private GameBoardMemberRepository gameBoardMemberRepository; - @Mock private GameRepository gameRepository; + @Mock private GameBoardMemberReader gameBoardMemberReader; + @Mock private GameReader gameReader; @Mock private GameBoardAccessValidator gameBoardAccessValidator; @Mock private GameBoardMemberAvailabilityPolicy availabilityPolicy; @Mock private GameMatchTypeSelector matchTypeSelector; @@ -70,8 +70,8 @@ void setUp() { board = GameFixture.gameBoard(BOARD_ID); service = new GameRandomMatchQueryService( gameBoardReader, - gameBoardMemberRepository, - gameRepository, + gameBoardMemberReader, + gameReader, gameBoardAccessValidator, availabilityPolicy, matchTypeSelector, @@ -96,9 +96,9 @@ void match_orchestratesRandomMatching() { GamePairHistory pairHistory = new GamePairHistoryCalculator().calculate(List.of()); given(gameBoardReader.read(BOARD_ID)).willReturn(board); - given(gameBoardMemberRepository.findByGameBoardIdOrderByIdAsc(BOARD_ID)) + given(gameBoardMemberReader.readAllByGameBoard(BOARD_ID)) .willReturn(members); - given(gameRepository.findByGameBoardIdAndStatusInWithPlayers( + given(gameReader.readAllByGameBoardAndStatuses( BOARD_ID, ACTIVE_STATUSES)).willReturn(activeGames); given(availabilityPolicy.filterAvailable( eq(members), eq(activeGames), any(LocalDateTime.class))).willReturn(members); @@ -108,7 +108,7 @@ void match_orchestratesRandomMatching() { .willReturn(Optional.of(candidatePool)); given(matchTypeSelector.selectFrom(List.of(GameMatchType.MEN_DOUBLES))) .willReturn(GameMatchType.MEN_DOUBLES); - given(gameRepository.countCompletedGamePairs( + given(gameReader.readCompletedPairCounts( BOARD_ID, List.of(2L, 3L, 4L, 5L))).willReturn(pairCounts); given(pairHistoryCalculator.fromCounts(pairCounts, List.of())).willReturn(pairHistory); given(bestMatchSelector.select( @@ -125,13 +125,11 @@ void match_orchestratesRandomMatching() { then(candidatePoolSelector).should() .find(candidates, GameMatchType.MEN_DOUBLES); then(matchTypeSelector).should().selectFrom(List.of(GameMatchType.MEN_DOUBLES)); - then(gameRepository).should().countCompletedGamePairs( + then(gameReader).should().readCompletedPairCounts( BOARD_ID, List.of(2L, 3L, 4L, 5L)); then(pairHistoryCalculator).should().fromCounts(pairCounts, List.of()); then(bestMatchSelector).should() .select(candidatePool, GameMatchType.MEN_DOUBLES, pairHistory); - then(gameRepository).should(never()).save(any(Game.class)); - then(gameBoardMemberRepository).should(never()).save(any(GameBoardMember.class)); } @Test @@ -145,9 +143,9 @@ void match_rejectsInsufficientAvailablePlayers() { List activeGames = List.of(); given(gameBoardReader.read(BOARD_ID)).willReturn(board); - given(gameBoardMemberRepository.findByGameBoardIdOrderByIdAsc(BOARD_ID)) + given(gameBoardMemberReader.readAllByGameBoard(BOARD_ID)) .willReturn(members); - given(gameRepository.findByGameBoardIdAndStatusInWithPlayers( + given(gameReader.readAllByGameBoardAndStatuses( BOARD_ID, ACTIVE_STATUSES)).willReturn(activeGames); given(availabilityPolicy.filterAvailable( eq(members), eq(activeGames), any(LocalDateTime.class))).willReturn(members); @@ -161,7 +159,7 @@ void match_rejectsInsufficientAvailablePlayers() { then(candidatePoolSelector).shouldHaveNoInteractions(); then(pairHistoryCalculator).shouldHaveNoInteractions(); then(bestMatchSelector).shouldHaveNoInteractions(); - then(gameRepository).should(never()).countCompletedGamePairs(eq(BOARD_ID), anyList()); + then(gameReader).should(never()).readCompletedPairCounts(eq(BOARD_ID), anyList()); } @Test @@ -180,8 +178,8 @@ void match_selectsAmongTypesWithFeasibleCandidatePool() { GamePairHistory pairHistory = new GamePairHistoryCalculator().calculate(List.of()); GameRandomMatchQueryService serviceWithRealSelectors = new GameRandomMatchQueryService( gameBoardReader, - gameBoardMemberRepository, - gameRepository, + gameBoardMemberReader, + gameReader, gameBoardAccessValidator, availabilityPolicy, new GameMatchTypeSelector(), @@ -190,13 +188,13 @@ void match_selectsAmongTypesWithFeasibleCandidatePool() { bestMatchSelector); given(gameBoardReader.read(BOARD_ID)).willReturn(board); - given(gameBoardMemberRepository.findByGameBoardIdOrderByIdAsc(BOARD_ID)) + given(gameBoardMemberReader.readAllByGameBoard(BOARD_ID)) .willReturn(members); - given(gameRepository.findByGameBoardIdAndStatusInWithPlayers( + given(gameReader.readAllByGameBoardAndStatuses( BOARD_ID, ACTIVE_STATUSES)).willReturn(activeGames); given(availabilityPolicy.filterAvailable( eq(members), eq(activeGames), any(LocalDateTime.class))).willReturn(members); - given(gameRepository.countCompletedGamePairs( + given(gameReader.readCompletedPairCounts( BOARD_ID, List.of(1L, 2L, 3L, 4L))).willReturn(pairCounts); given(pairHistoryCalculator.fromCounts(pairCounts, List.of())).willReturn(pairHistory); given(bestMatchSelector.select( diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/reader/GameBoardMemberReaderTest.java b/src/test/java/umc/cockple/demo/domain/game/service/support/reader/GameBoardMemberReaderTest.java index c17d09483..82e943e40 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/support/reader/GameBoardMemberReaderTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/support/reader/GameBoardMemberReaderTest.java @@ -61,6 +61,29 @@ void countByGameBoard_delegatesToRepository() { assertThat(gameBoardMemberReader.countByGameBoard(GAME_BOARD_ID)).isEqualTo(3L); } + @Test + @DisplayName("게임판 전체 명단을 ID 오름차순으로 조회한다") + void readAllByGameBoard_delegatesToRepository() { + given(gameBoardMemberRepository.findByGameBoardIdOrderByIdAsc(GAME_BOARD_ID)) + .willReturn(List.of(gameBoardMember)); + + assertThat(gameBoardMemberReader.readAllByGameBoard(GAME_BOARD_ID)) + .containsExactly(gameBoardMember); + } + + @Test + @DisplayName("게임판과 명단 ID 목록으로 명단을 조회한다") + void readAllByGameBoardAndIds_delegatesToRepository() { + List gameBoardMemberIds = List.of(2L, 3L); + given(gameBoardMemberRepository.findByGameBoardIdAndIdIn( + GAME_BOARD_ID, gameBoardMemberIds)) + .willReturn(List.of(gameBoardMember)); + + assertThat(gameBoardMemberReader.readAllByGameBoardAndIds( + GAME_BOARD_ID, gameBoardMemberIds)) + .containsExactly(gameBoardMember); + } + @Test @DisplayName("게임판 명단 필터 조건을 repository에 전달한다") void readAllByFilters_delegatesToRepository() { diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/reader/GameReaderTest.java b/src/test/java/umc/cockple/demo/domain/game/service/support/reader/GameReaderTest.java index 961ad0b70..235f656c7 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/support/reader/GameReaderTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/support/reader/GameReaderTest.java @@ -7,6 +7,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import umc.cockple.demo.domain.game.domain.Game; +import umc.cockple.demo.domain.game.domain.service.GamePairCount; import umc.cockple.demo.domain.game.enums.GameStatus; import umc.cockple.demo.domain.game.repository.GameRepository; @@ -49,4 +50,27 @@ void existsByGameBoardMemberAndStatuses_delegatesToRepository() { then(gameRepository).should() .existsByGameBoardMemberIdAndStatusIn(gameBoardMemberId, statuses); } + + @Test + @DisplayName("완료 경기의 대상 멤버 페어 집계를 조회한다") + void readCompletedPairCounts_delegatesToRepository() { + List gameBoardMemberIds = List.of(10L, 20L); + List pairCounts = List.of(new GamePairCount(10L, 20L, 3)); + given(gameRepository.countCompletedGamePairs(GAME_BOARD_ID, gameBoardMemberIds)) + .willReturn(pairCounts); + + assertThat(gameReader.readCompletedPairCounts(GAME_BOARD_ID, gameBoardMemberIds)) + .containsExactlyElementsOf(pairCounts); + } + + @Test + @DisplayName("가장 최근 완료 경기의 멤버 ID를 조회한다") + void readLatestCompletedGameMemberIds_delegatesToRepository() { + List memberIds = List.of(10L, 20L, 30L, 40L); + given(gameRepository.findLatestCompletedGameMemberIds(GAME_BOARD_ID)) + .willReturn(memberIds); + + assertThat(gameReader.readLatestCompletedGameMemberIds(GAME_BOARD_ID)) + .containsExactlyElementsOf(memberIds); + } } From 8a2c2bbc7a7c1b219defbfbdf69d9875bf0d3ac4 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Tue, 25 Aug 2026 07:09:42 +0900 Subject: [PATCH 14/14] =?UTF-8?q?chore:=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/domain/game/service/command/GameCommandService.java | 2 +- .../domain/game/service/command/GameCommandServiceTest.java | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java b/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java index 5633b5790..1b2b2a525 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java @@ -10,6 +10,7 @@ import umc.cockple.demo.domain.game.domain.GameBoard; import umc.cockple.demo.domain.game.domain.GameBoardMember; import umc.cockple.demo.domain.game.domain.GamePlayer; +import umc.cockple.demo.domain.game.domain.service.GameBoardMemberAvailabilityPolicy; import umc.cockple.demo.domain.game.enums.GameStatus; import umc.cockple.demo.domain.game.events.GameBoardMembersChangedEvent; import umc.cockple.demo.domain.game.exception.GameErrorCode; @@ -25,7 +26,6 @@ import umc.cockple.demo.domain.game.service.command.result.GameDeleteResult; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; import umc.cockple.demo.domain.game.service.support.validator.GameBoardAccessValidator; -import umc.cockple.demo.domain.game.service.support.validator.GameBoardMemberAvailabilityPolicy; import java.time.LocalDateTime; import java.util.List; diff --git a/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java index 030f6a775..49e6ca0c7 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java @@ -15,6 +15,7 @@ import umc.cockple.demo.domain.game.domain.GameBoard; import umc.cockple.demo.domain.game.domain.GameBoardMember; import umc.cockple.demo.domain.game.domain.GamePlayer; +import umc.cockple.demo.domain.game.domain.service.GameBoardMemberAvailabilityPolicy; import umc.cockple.demo.domain.game.enums.GameStatus; import umc.cockple.demo.domain.game.events.GameBoardMembersChangedEvent; import umc.cockple.demo.domain.game.exception.GameErrorCode; @@ -30,7 +31,6 @@ import umc.cockple.demo.domain.game.service.command.result.GameDeleteResult; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; import umc.cockple.demo.domain.game.service.support.validator.GameBoardAccessValidator; -import umc.cockple.demo.domain.game.service.support.validator.GameBoardMemberAvailabilityPolicy; import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.support.fixture.GameFixture; @@ -42,8 +42,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.*; import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class)