diff --git a/README.md b/README.md index 8d7e8aee..b748ab1a 100644 --- a/README.md +++ b/README.md @@ -1 +1,26 @@ -# java-baseball-precourse \ No newline at end of file +# java-baseball-precourse + +1. [domains] Baseball 정답 생성기 +- 1에서 9까지 서로 다른 임의의 수 3개를 선택하여 저장한다. + +2. [domains] Baseball 판독기 (핵심 로직) +- 플레이어가 입력한 수와 컴퓨터의 수를 비교한다. + +- 같은 수가 같은 자리에 있으면 스트라이크 개수를 센다. + +- 같은 수가 다른 자리에 있으면 볼 개수를 센다. + +- 두 숫자의 비교 결과를 GameResult와 같은 객체로 반환한다. + +3. [controllers] Baseball 게임 진행 +- 3 스트라이크인 경우 게임 종료를 판정한다. +- 게임 종료 후 재시작(1) 또는 완전히 종료(2) 여부를 판단한다. + +4. [validator] 입력기 및 에러 핸들러 +- 잘못된 값을 입력할 경우 IllegalArgumentException을 발생시킨다. +- 숫자만 입력했는지, 3자리인지, 중복이 없는지 검증한다. +- 예외 발생 시 [ERROR]로 시작하는 메시지를 출력하고 게임을 지속한다. + +5. [View] 출력기 +- 입력한 숫자에 대한 결과(볼, 스트라이크, 낫싱)를 형식에 맞춰 출력한다. +- 게임 종료 시와 에러 시에 적절한 메시지를 화면에 출력한다. diff --git a/src/main/java/.gitkeep b/src/main/java/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/Application.java b/src/main/java/Application.java new file mode 100644 index 00000000..745f7829 --- /dev/null +++ b/src/main/java/Application.java @@ -0,0 +1,14 @@ +import controllers.GameController; +import views.InputView; +import views.OutputView; + +public class Application { + public static void main(String[] args) { + InputView inputView = new InputView(); + OutputView outputView = new OutputView(); + + GameController controller = new GameController(inputView, outputView); + + controller.run(); + } +} \ No newline at end of file diff --git a/src/main/java/controllers/GameController.java b/src/main/java/controllers/GameController.java new file mode 100644 index 00000000..4f1fffe4 --- /dev/null +++ b/src/main/java/controllers/GameController.java @@ -0,0 +1,72 @@ +package controllers; + +import domains.BaseballNumbers; +import domains.GameResult; +import domains.NumberGenerator; +import domains.Validator; +import views.InputView; +import views.OutputView; + +import java.util.ArrayList; +import java.util.List; + +public class GameController { + private final InputView inputView; + private final OutputView outputView; // 출력 담당 클래스 (가정) + private BaseballNumbers computerNumbers; + + public GameController(InputView inputView, OutputView outputView) { + this.inputView = inputView; + this.outputView = outputView; + } + + public void run() { + do { + playGame(); + } while (isRestartRequested()); + } + + private void playGame() { + // 1. 컴퓨터 숫자 생성 + computerNumbers = NumberGenerator.generate(); + boolean isGameWon = false; + // 2. 맞출 때까지 반복 + while (!isGameWon) { + isGameWon = playTurn(computerNumbers); // 루프 내부 로직 분리 (15라인 제한 준수) + } + outputView.printGameEnd(); + } + + private boolean playTurn(BaseballNumbers computerNumbers) { + try { + String input = inputView.readNumbers(); + Validator.validateInput(input); + GameResult result = computerNumbers.compare(parseInput(input)); + outputView.printResult(result); + return result.isThreeStrike(); + } catch (IllegalArgumentException e) { + outputView.printErrorMessage(e.getMessage()); // UI 일관성 유지 + return false; + } + } + + private boolean isRestartRequested() { + try { + String command = inputView.readRestartCommand(); + Validator.validateRestartCommand(command); + return command.equals("1"); + } catch (IllegalArgumentException e) { + System.out.println("[ERROR] " + e.getMessage()); + return isRestartRequested(); // 잘못 입력하면 재귀적으로 다시 물어봄 + } + } + + private List parseInput(String input) { + List numbers = new ArrayList<>(); + for (char c : input.toCharArray()) { + // 문자 '1'을 숫자 1로 변환해서 리스트에 추가 + numbers.add(Character.getNumericValue(c)); + } + return numbers; + } +} \ No newline at end of file diff --git a/src/main/java/domains/BaseballNumbers.java b/src/main/java/domains/BaseballNumbers.java new file mode 100644 index 00000000..8d76e7c3 --- /dev/null +++ b/src/main/java/domains/BaseballNumbers.java @@ -0,0 +1,42 @@ +package domains; + +import java.util.List; +import java.util.Collections; + +public class BaseballNumbers { + private final List numbers; + + public BaseballNumbers(List numbers) { + this.numbers = numbers; + } + + public GameResult compare(List otherNumbers) { + int strikes = 0; + int balls = 0; + + for (int i = 0; i < numbers.size(); i++) { + if (isStrike(i, otherNumbers)) { + strikes++; + } + } + + for (int i = 0; i < numbers.size(); i++) { + if (isBall(i, otherNumbers)) { + balls++; + } + } + return new GameResult(strikes, balls); + } + + public List getNumbers() { + return Collections.unmodifiableList(numbers); + } + + private boolean isStrike(int index, List otherNumbers) { + return numbers.get(index).equals(otherNumbers.get(index)); + } + + private boolean isBall(int index, List otherNumbers) { + return !isStrike(index, otherNumbers) && numbers.contains(otherNumbers.get(index)); + } +} \ No newline at end of file diff --git a/src/main/java/domains/GameResult.java b/src/main/java/domains/GameResult.java new file mode 100644 index 00000000..46eb69b3 --- /dev/null +++ b/src/main/java/domains/GameResult.java @@ -0,0 +1,28 @@ +package domains; + +public class GameResult { + private final int strikes; + private final int balls; + + public GameResult(int strikes, int balls) { + this.strikes = strikes; + this.balls = balls; + } + + public boolean isThreeStrike() { + return strikes == 3; + } + + public boolean isNothing() { + return strikes == 0 && balls == 0; + } + + // OutputView에서 사용하기 편하도록 Getter 제공 + public int getStrikes() { + return strikes; + } + + public int getBalls() { + return balls; + } +} \ No newline at end of file diff --git a/src/main/java/domains/NumberGenerator.java b/src/main/java/domains/NumberGenerator.java new file mode 100644 index 00000000..3636ab37 --- /dev/null +++ b/src/main/java/domains/NumberGenerator.java @@ -0,0 +1,24 @@ +package domains; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.Random; +import java.util.Set; + +public class NumberGenerator { + private static final int NUMBER_COUNT = 3; + private static final int MIN_NUMBER = 1; + private static final int MAX_NUMBER = 9; + + public static BaseballNumbers generate() { + Set uniqueNumbers = new LinkedHashSet<>(); + Random random = new Random(); + + while (uniqueNumbers.size() < NUMBER_COUNT) { + int randomNumber = random.nextInt(MAX_NUMBER - MIN_NUMBER + 1) + MIN_NUMBER; + uniqueNumbers.add(randomNumber); + } + + return new BaseballNumbers(new ArrayList<>(uniqueNumbers)); + } +} \ No newline at end of file diff --git a/src/main/java/domains/Validator.java b/src/main/java/domains/Validator.java new file mode 100644 index 00000000..300d15d4 --- /dev/null +++ b/src/main/java/domains/Validator.java @@ -0,0 +1,36 @@ +package domains; + +import java.util.HashSet; +import java.util.Set; + +public class Validator { + public static void validateRestartCommand(String input) { + if (!input.equals("1") && !input.equals("2")) { + throw new IllegalArgumentException("[ERROR] 1 또는 2만 입력해야 합니다."); + } + } + + public static void validateInput(String input) { + if (!isNumeric(input)) { + throw new IllegalArgumentException("[ERROR] 숫자만 입력 가능합니다."); + } + if (input.length() != 3) { + throw new IllegalArgumentException("[ERROR] 3자리 숫자여야 합니다."); + } + if (hasDuplicate(input)) { + throw new IllegalArgumentException("[ERROR] 중복된 숫자가 있습니다."); + } + } + + private static boolean isNumeric(String str) { + return str.matches("^[1-9]+$"); // 1-9 사이의 숫자인지 확인 + } + + private static boolean hasDuplicate(String str) { + Set uniqueChars = new HashSet<>(); + for (char c : str.toCharArray()) { + uniqueChars.add(c); + } + return uniqueChars.size() != str.length(); + } +} \ No newline at end of file diff --git a/src/main/java/views/InputView.java b/src/main/java/views/InputView.java new file mode 100644 index 00000000..9a3508fb --- /dev/null +++ b/src/main/java/views/InputView.java @@ -0,0 +1,19 @@ +package views; + +import java.util.Scanner; + +public class InputView { + private static final Scanner scanner = new Scanner(System.in); + + // 3자리 숫자 입력 받기 + public String readNumbers() { + System.out.print("숫자를 입력해주세요 : "); + return scanner.nextLine(); + } + + // 재시작 또는 종료 버튼 입력 받기 (1 또는 2) + public String readRestartCommand() { + System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요."); + return scanner.nextLine(); + } +} diff --git a/src/main/java/views/OutputView.java b/src/main/java/views/OutputView.java new file mode 100644 index 00000000..55dc1a73 --- /dev/null +++ b/src/main/java/views/OutputView.java @@ -0,0 +1,40 @@ +package views; + +import domains.GameResult; + +public class OutputView { + private static final String BALL = "볼"; + private static final String STRIKE = "스트라이크"; + private static final String NOTHING = "낫싱"; + private static final String GAME_END_MESSAGE = "3개의 숫자를 모두 맞히셨습니다! 게임 종료"; + + public void printResult(GameResult result) { + if (result.isNothing()) { + System.out.println(NOTHING); + return; + } + + System.out.println(formatResult(result.getBalls(), result.getStrikes())); + } + + private String formatResult(int balls, int strikes) { + StringBuilder sb = new StringBuilder(); + + if (balls > 0) { + sb.append(balls).append(BALL).append(" "); + } + if (strikes > 0) { + sb.append(strikes).append(STRIKE); + } + + return sb.toString().trim(); + } + + public void printGameEnd() { + System.out.println(GAME_END_MESSAGE); + } + + public void printErrorMessage(String message) { + System.out.println("[ERROR] " + message); + } +} \ No newline at end of file diff --git a/src/test/java/.gitkeep b/src/test/java/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/test/java/GameResultTest.java b/src/test/java/GameResultTest.java new file mode 100644 index 00000000..e2e2a790 --- /dev/null +++ b/src/test/java/GameResultTest.java @@ -0,0 +1,36 @@ +package domains; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class GameResultTest { + @DisplayName("3 스트라이크일 때 승리 여부를 정확히 판정하는지 확인한다.") + @Test + void isThreeStrike_True_WhenStrikesIsThree() { + GameResult result = new GameResult(3, 0); + assertThat(result.isThreeStrike()).isTrue(); + } + + @DisplayName("스트라이크와 볼이 모두 0이면 '낫싱'으로 판정한다.") + @Test + void isNothing_True_WhenNoStrikeAndNoBall() { + GameResult result = new GameResult(0, 0); + assertThat(result.isNothing()).isTrue(); + } + + @DisplayName("하나라도 맞으면 '낫싱'이 아니다.") + @ParameterizedTest + @CsvSource({ + "1, 0", + "0, 1", + "1, 1" + }) + void isNothing_False_WhenAnyMatch(int strikes, int balls) { + GameResult result = new GameResult(strikes, balls); + assertThat(result.isNothing()).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/NumberGeneratorTest.java b/src/test/java/NumberGeneratorTest.java new file mode 100644 index 00000000..291b79b6 --- /dev/null +++ b/src/test/java/NumberGeneratorTest.java @@ -0,0 +1,40 @@ +package domains; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; + +class NumberGeneratorTest { + + @DisplayName("생성된 숫자는 정확히 3개여야 한다.") + @Test + void generateNumberCountTest() { + BaseballNumbers numbers = NumberGenerator.generate(); + assertThat(numbers.getNumbers()).hasSize(3); + } + + @DisplayName("생성된 숫자는 1에서 9 사이의 범위여야 한다.") + @RepeatedTest(100) // 난수 생성이므로 여러 번 반복해서 검증 + void generateNumberRangeTest() { + BaseballNumbers numbers = NumberGenerator.generate(); + + for (int number : numbers.getNumbers()) { + assertThat(number).isBetween(1, 9); + } + } + + @DisplayName("생성된 숫자들 사이에는 중복이 없어야 한다.") + @Test + void generateUniqueNumberTest() { + BaseballNumbers numbers = NumberGenerator.generate(); + List numberList = numbers.getNumbers(); + + // Set에 넣었을 때도 크기가 3이라면 중복이 없는 것 + assertThat(Set.copyOf(numberList)).hasSize(3); + } +} \ No newline at end of file diff --git a/src/test/java/ValidatorTest.java b/src/test/java/ValidatorTest.java new file mode 100644 index 00000000..347f18fe --- /dev/null +++ b/src/test/java/ValidatorTest.java @@ -0,0 +1,62 @@ +package domains; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ValidatorTest { + + @DisplayName("재시작 명령어가 '1' 또는 '2'가 아니면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"3", "a", "", " ", "12"}) + void validateRestartCommand_Exception(String input) { + assertThatThrownBy(() -> Validator.validateRestartCommand(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR] 1 또는 2만 입력해야 합니다."); + } + + @DisplayName("정상적인 재시작 명령어인 경우 예외가 발생하지 않는다.") + @ParameterizedTest + @ValueSource(strings = {"1", "2"}) + void validateRestartCommand_Success(String input) { + assertThatCode(() -> Validator.validateRestartCommand(input)) + .doesNotThrowAnyException(); + } + + @DisplayName("숫자 야구 입력값이 1-9 사이의 숫자가 아니거나 문자가 포함되면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"012", "a12", "1 2", "1.2", ""}) + void validateInput_NotNumeric_Exception(String input) { + assertThatThrownBy(() -> Validator.validateInput(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR] 숫자만 입력 가능합니다."); + } + + @DisplayName("숫자 야구 입력값이 3자리가 아니면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"12", "1234"}) // ""(빈 문자열) 제거 + void validateInput_Length_Exception(String input) { + assertThatThrownBy(() -> Validator.validateInput(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR] 3자리 숫자여야 합니다."); + } + + @DisplayName("숫자 야구 입력값에 중복된 숫자가 있으면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"112", "121", "222"}) + void validateInput_Duplicate_Exception(String input) { + assertThatThrownBy(() -> Validator.validateInput(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR] 중복된 숫자가 있습니다."); + } + + @DisplayName("올바른 숫자 야구 입력값인 경우 예외가 발생하지 않는다.") + @Test + void validateInput_Success() { + assertThatCode(() -> Validator.validateInput("123")) + .doesNotThrowAnyException(); + } +} \ No newline at end of file