diff --git a/README.md b/README.md index 8d7e8aee..05bb0853 100644 --- a/README.md +++ b/README.md @@ -1 +1,42 @@ -# java-baseball-precourse \ No newline at end of file +# java-baseball-precourse + +## 기능 목록 + +### 1. 야구 숫자 (Model - BaseballNumber) + +- [x] 1에서 9 사이의 서로 다른 3자리 숫자를 저장한다. +- [x] 입력받은 숫자의 유효성을 검사한다. + - [x] 3자리 숫자인지 확인 + - [x] 1~9 사이의 숫자인지 확인 + - [x] 중복된 숫자가 없는지 확인 +- [x] 1에서 9 사이의 서로 다른 임의의 수 3개를 생성한다. + +### 2. 점수 계산 (Model - Score) + +- [x] 스트라이크와 볼의 개수를 저장한다. +- [x] 모든 숫자를 맞혔는지(3스트라이크) 확인한다. +- [x] 하나도 맞히지 못했는지(낫싱) 확인한다. + +### 3. 정답 및 비교 로직 (Model - Answer) + +- [x] 컴퓨터의 정답 숫자를 관리한다. +- [x] 사용자의 입력 숫자와 비교하여 스트라이크/볼 결과를 도출한다. + +### 4. 게임 종료 명령어 (Model - GameCommand) + +- [x] 게임 종료 후 재시작(1) 또는 종료(2) 입력을 관리한다. +- [x] 1 또는 2 이외의 입력에 대해 예외를 발생시킨다. + +### 5. 입출력 (View) + +- [x] 안내 메시지 및 게임 결과를 출력한다. + - [x] 게임 시작 메시지 출력 + - [x] 입력 유도 메시지 출력 + - [x] 판정 결과(n볼 n스트라이크, 낫싱 등) 출력 + - [x] 게임 종료 및 재시작 안내 메시지 출력 +- [x] 사용자로부터 문자열을 입력받는다. + +### 6. 게임 흐름 제어 (Controller & Application) + +- [x] 전체적인 게임 루프를 관리한다. +- [x] 사용자가 잘못된 값을 입력할 경우 `IllegalArgumentException`을 발생시키고, "[ERROR]"로 시작하는 메시지를 출력한 후 입력을 다시 받는다. diff --git a/src/main/java/baseball/Application.java b/src/main/java/baseball/Application.java new file mode 100644 index 00000000..af6aa81f --- /dev/null +++ b/src/main/java/baseball/Application.java @@ -0,0 +1,12 @@ +package baseball; + +import baseball.application.BaseballRunner; +import baseball.config.AppConfig; + +public class Application { + public static void main(String[] args) { + AppConfig config = new AppConfig(); + BaseballRunner runner = new BaseballRunner(config); + runner.run(); + } +} diff --git a/src/main/java/baseball/application/BaseballRunner.java b/src/main/java/baseball/application/BaseballRunner.java new file mode 100644 index 00000000..4d9556d7 --- /dev/null +++ b/src/main/java/baseball/application/BaseballRunner.java @@ -0,0 +1,68 @@ +package baseball.application; + +import baseball.config.AppConfig; +import baseball.controller.BaseballController; +import baseball.model.Answer; +import baseball.model.GameCommand; +import baseball.model.Score; +import baseball.utils.ConsoleInputReader; +import baseball.view.*; + +public class BaseballRunner { + private final BaseballController baseballController; + private final ConsoleInputReader input; + private final ConsoleViewRenderer renderer; + + public BaseballRunner(AppConfig config) { + this.baseballController = config.baseballController(); + this.input = config.inputReader(); + this.renderer = config.viewRenderer(); + } + + public void run() { + while (true) { + playOneGame(Answer.random()); + + if (isEnd()) { + return; + } + } + } + + private void playOneGame(Answer answer) { + while (true) { + if (playOneTurn(answer)) { + break; + } + } + } + + private boolean playOneTurn(Answer answer) { + renderer.render(new InputConsoleView()); + String inputNumber = input.readLine(); + + try { + Score score = baseballController.guess(answer, inputNumber); + renderer.render(new ResultConsoleView(score)); + + if (score.isAllStrike()) { + renderer.render(new EndConsoleView(score)); + return true; + } + } catch (IllegalArgumentException e) { + renderer.render(new ErrorConsoleView(e.getMessage())); + } + return false; + } + + private boolean isEnd() { + while (true) { + try { + GameCommand cmd = baseballController.decideNextAction(input.readLine()); + return cmd == GameCommand.EXIT; + } catch (IllegalArgumentException e) { + renderer.render(new ErrorConsoleView(e.getMessage())); + } + } + } +} diff --git a/src/main/java/baseball/config/AppConfig.java b/src/main/java/baseball/config/AppConfig.java new file mode 100644 index 00000000..5e1041f0 --- /dev/null +++ b/src/main/java/baseball/config/AppConfig.java @@ -0,0 +1,25 @@ +package baseball.config; + +import baseball.controller.BaseballController; +import baseball.utils.ConsoleInputReader; +import baseball.view.ConsoleViewRenderer; + +import java.util.Scanner; + +public class AppConfig { + private Scanner scanner() { + return new Scanner(System.in); + } + + public ConsoleInputReader inputReader() { + return new ConsoleInputReader(scanner()); + } + + public ConsoleViewRenderer viewRenderer() { + return new ConsoleViewRenderer(); + } + + public BaseballController baseballController() { + return new BaseballController(); + } +} diff --git a/src/main/java/baseball/controller/BaseballController.java b/src/main/java/baseball/controller/BaseballController.java new file mode 100644 index 00000000..4e943fd4 --- /dev/null +++ b/src/main/java/baseball/controller/BaseballController.java @@ -0,0 +1,17 @@ +package baseball.controller; + +import baseball.model.Answer; +import baseball.model.BaseballNumber; +import baseball.model.GameCommand; +import baseball.model.Score; + +public class BaseballController { + public Score guess(Answer answer, String number) { + BaseballNumber guessNumber = BaseballNumber.fromString(number); + return answer.compare(guessNumber); + } + + public GameCommand decideNextAction(String input) { + return GameCommand.from(input); + } +} diff --git a/src/main/java/baseball/model/Answer.java b/src/main/java/baseball/model/Answer.java new file mode 100644 index 00000000..aee7d507 --- /dev/null +++ b/src/main/java/baseball/model/Answer.java @@ -0,0 +1,52 @@ +package baseball.model; + +public class Answer { + private final BaseballNumber number; + + private Answer(BaseballNumber number) { + this.number = number; + } + + public static Answer random() { + return new Answer(BaseballNumber.random()); + } + + public static Answer from(BaseballNumber number) { + return new Answer(number); + } + + public Score compare(BaseballNumber guess) { + int strikes = countStrikes(guess); + int balls = countBalls(guess); + + return Score.of(strikes, balls, guess.size()); + } + + private int countStrikes(BaseballNumber guess) { + int count = 0; + for (int i = 0; i < number.size(); i++) { + if (isStrike(i, guess.digitAt(i))) { + count++; + } + } + return count; + } + + private int countBalls(BaseballNumber guess) { + int count = 0; + for (int i = 0; i < number.size(); i++) { + if (isBall(i, guess.digitAt(i))) { + count++; + } + } + return count; + } + + private boolean isStrike(int index, int digit) { + return number.digitAt(index) == digit; + } + + private boolean isBall(int index, int digit) { + return !isStrike(index, digit) && number.contains(digit); + } +} diff --git a/src/main/java/baseball/model/BaseballNumber.java b/src/main/java/baseball/model/BaseballNumber.java new file mode 100644 index 00000000..844ebcfd --- /dev/null +++ b/src/main/java/baseball/model/BaseballNumber.java @@ -0,0 +1,77 @@ +package baseball.model; + + +import java.util.*; + +public class BaseballNumber { + static final int LENGTH = 3; + private final List digits; + + private BaseballNumber(List digits) { + this.digits = List.copyOf(digits); + } + + public static BaseballNumber fromString(String raw) { + String input = raw.trim(); + validate(input); + + List digits = new ArrayList<>(); + for (char c : input.toCharArray()) { + digits.add(c - '0'); + } + return new BaseballNumber(digits); + } + + public static BaseballNumber random() { + List pool = new ArrayList<>(); + for (int i = 1; i <= 9; i++) { + pool.add(i); + } + Collections.shuffle(pool); + return new BaseballNumber(pool.subList(0, LENGTH)); + } + + private static void validate(String input) { + validateLength(input); + validateElements(input); + } + + private static void validateLength(String input) { + if (input.length() != LENGTH) { + throw new IllegalArgumentException("입력 숫자는 " + LENGTH + "자리여야 합니다."); + } + } + + private static void validateElements(String input) { + Set uniqueChars = new HashSet<>(); + for (char c : input.toCharArray()) { + validateRange(c); + validateUniqueness(uniqueChars, c); + uniqueChars.add(c); + } + } + + private static void validateRange(char c) { + if (c < '1' || c > '9') { + throw new IllegalArgumentException("1 에서 9 사이의 숫자만 입력 가능합니다."); + } + } + + private static void validateUniqueness(Set uniqueChars, char c) { + if (uniqueChars.contains(c)) { + throw new IllegalArgumentException("중복되지 않은 숫자여야 합니다."); + } + } + + public int size() { + return digits.size(); + } + + public int digitAt(int index) { + return digits.get(index); + } + + public boolean contains(int digit) { + return digits.contains(digit); + } +} diff --git a/src/main/java/baseball/model/GameCommand.java b/src/main/java/baseball/model/GameCommand.java new file mode 100644 index 00000000..786b7d41 --- /dev/null +++ b/src/main/java/baseball/model/GameCommand.java @@ -0,0 +1,27 @@ +package baseball.model; + +public enum GameCommand { + RESTART("1"), + EXIT("2"); + + private final String code; + + GameCommand(String code) { + this.code = code; + } + + public static GameCommand from(String rawInput) { + String input = rawInput.trim(); + + for (GameCommand command : values()) { + if (command.code.equals(input)) { + return command; + } + } + throw new IllegalArgumentException("1(재시작) 또는 2(종료)를 입력하세요."); + } + + public String getCode() { + return code; + } +} diff --git a/src/main/java/baseball/model/Score.java b/src/main/java/baseball/model/Score.java new file mode 100644 index 00000000..87aa9c2e --- /dev/null +++ b/src/main/java/baseball/model/Score.java @@ -0,0 +1,36 @@ +package baseball.model; + +public class Score { + private final int strikes; + private final int balls; + private final int length; + + private Score(int strikes, int balls, int length) { + this.strikes = strikes; + this.balls = balls; + this.length = length; + } + + public static Score of(int strikes, int balls, int length) { + if (strikes < 0 || balls < 0) { + throw new IllegalArgumentException("스트라이크/볼은 음수일 수 없습니다."); + } + return new Score(strikes, balls, length); + } + + public int getStrikes() { + return strikes; + } + + public int getBalls() { + return balls; + } + + public boolean isAllStrike() { + return strikes == length; + } + + public boolean isNothing() { + return strikes == 0 && balls == 0; + } +} diff --git a/src/main/java/baseball/utils/ConsoleInputReader.java b/src/main/java/baseball/utils/ConsoleInputReader.java new file mode 100644 index 00000000..a9600593 --- /dev/null +++ b/src/main/java/baseball/utils/ConsoleInputReader.java @@ -0,0 +1,15 @@ +package baseball.utils; + +import java.util.Scanner; + +public class ConsoleInputReader { + private final Scanner scanner; + + public ConsoleInputReader(Scanner scanner) { + this.scanner = scanner; + } + + public String readLine() { + return scanner.nextLine(); + } +} diff --git a/src/main/java/baseball/view/ConsoleView.java b/src/main/java/baseball/view/ConsoleView.java new file mode 100644 index 00000000..d4add96b --- /dev/null +++ b/src/main/java/baseball/view/ConsoleView.java @@ -0,0 +1,5 @@ +package baseball.view; + +public interface ConsoleView { + String asString(); +} diff --git a/src/main/java/baseball/view/ConsoleViewRenderer.java b/src/main/java/baseball/view/ConsoleViewRenderer.java new file mode 100644 index 00000000..5793d830 --- /dev/null +++ b/src/main/java/baseball/view/ConsoleViewRenderer.java @@ -0,0 +1,7 @@ +package baseball.view; + +public class ConsoleViewRenderer { + public void render(ConsoleView cv) { + System.out.print(cv.asString()); + } +} diff --git a/src/main/java/baseball/view/EndConsoleView.java b/src/main/java/baseball/view/EndConsoleView.java new file mode 100644 index 00000000..b2344047 --- /dev/null +++ b/src/main/java/baseball/view/EndConsoleView.java @@ -0,0 +1,19 @@ +package baseball.view; + +import baseball.model.GameCommand; +import baseball.model.Score; + +public class EndConsoleView implements ConsoleView { + private final Score score; + + public EndConsoleView(Score score) { + this.score = score; + } + + @Override + public String asString() { + int strikes = score.getStrikes(); + return strikes + "개의 숫자를 모두 맞히셨습니다! 게임 끝\n" + + "게임 새로 시작하려면 " + GameCommand.RESTART.getCode() + ", 종료하려면 " + GameCommand.EXIT.getCode() + "를 입력하세요.\n"; + } +} diff --git a/src/main/java/baseball/view/ErrorConsoleView.java b/src/main/java/baseball/view/ErrorConsoleView.java new file mode 100644 index 00000000..7d683049 --- /dev/null +++ b/src/main/java/baseball/view/ErrorConsoleView.java @@ -0,0 +1,14 @@ +package baseball.view; + +public class ErrorConsoleView implements ConsoleView { + private final String errorMessage; + + public ErrorConsoleView(String errorMessage) { + this.errorMessage = errorMessage; + } + + @Override + public String asString() { + return "[ERROR] " + errorMessage + "\n"; + } +} diff --git a/src/main/java/baseball/view/InputConsoleView.java b/src/main/java/baseball/view/InputConsoleView.java new file mode 100644 index 00000000..a2c11668 --- /dev/null +++ b/src/main/java/baseball/view/InputConsoleView.java @@ -0,0 +1,8 @@ +package baseball.view; + +public class InputConsoleView implements ConsoleView { + @Override + public String asString() { + return "숫자를 입력해주세요 : "; + } +} diff --git a/src/main/java/baseball/view/ResultConsoleView.java b/src/main/java/baseball/view/ResultConsoleView.java new file mode 100644 index 00000000..8989b29a --- /dev/null +++ b/src/main/java/baseball/view/ResultConsoleView.java @@ -0,0 +1,29 @@ +package baseball.view; + +import baseball.model.Score; + +public class ResultConsoleView implements ConsoleView { + private final Score score; + + public ResultConsoleView(Score score) { + this.score = score; + } + + @Override + public String asString() { + if (score.isNothing()) { + return "낫싱\n"; + } + + StringBuilder sb = new StringBuilder(); + + if (score.getStrikes() > 0) { + sb.append(score.getStrikes()).append("스트라이크 "); + } + if (score.getBalls() > 0) { + sb.append(score.getBalls()).append("볼"); + } + + return sb.toString().trim() + "\n"; + } +} diff --git a/src/test/java/baseball/model/AnswerTest.java b/src/test/java/baseball/model/AnswerTest.java new file mode 100644 index 00000000..574c20e5 --- /dev/null +++ b/src/test/java/baseball/model/AnswerTest.java @@ -0,0 +1,33 @@ +package baseball.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class AnswerTest { + + @DisplayName("정답과 입력을 비교하여 스트라이크와 볼 개수를 계산한다.") + @ParameterizedTest(name = "정답 : {0}, 입력 : {1} -> {2}스트라이크 {3}볼") + @CsvSource({ + "123, 123, 3, 0", + "123, 124, 2, 0", + "123, 132, 1, 2", + "123, 312, 0, 3", + "123, 456, 0, 0", + "123, 321, 1, 2", + "713, 123, 1, 1", + "713, 671, 0, 2" + }) + void compare(String answerStr, String guessStr, int expectedStrikes, int expectedBalls) { + BaseballNumber answerNumber = BaseballNumber.fromString(answerStr); + Answer answer = Answer.from(answerNumber); + BaseballNumber guessNumber = BaseballNumber.fromString(guessStr); + + Score score = answer.compare(guessNumber); + + assertThat(score.getStrikes()).isEqualTo(expectedStrikes); + assertThat(score.getBalls()).isEqualTo(expectedBalls); + } +} diff --git a/src/test/java/baseball/model/BaseballNumberTest.java b/src/test/java/baseball/model/BaseballNumberTest.java new file mode 100644 index 00000000..2c5f112c --- /dev/null +++ b/src/test/java/baseball/model/BaseballNumberTest.java @@ -0,0 +1,60 @@ +package baseball.model; + +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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BaseballNumberTest { + + @DisplayName("3자리 숫자로 생성할 수 있다.") + @ParameterizedTest + @ValueSource(strings = {"123", "456", "789", "159"}) + void create_valid(String input) { + BaseballNumber number = BaseballNumber.fromString(input); + assertThat(number).isNotNull(); + assertThat(number.size()).isEqualTo(3); + } + + @DisplayName("3자리가 아니면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"12", "1234", ""}) + void create_invalid_length(String input) { + assertThatThrownBy(() -> BaseballNumber.fromString(input)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("숫자가 아닌 문자가 포함되면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"12a", "abc", "1-3"}) + void create_non_numeric(String input) { + assertThatThrownBy(() -> BaseballNumber.fromString(input)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("0이 포함되면 예외가 발생한다.") + @Test + void create_with_zero() { + assertThatThrownBy(() -> BaseballNumber.fromString("102")) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("중복된 숫자가 있으면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"112", "121", "333"}) + void create_duplicate(String input) { + assertThatThrownBy(() -> BaseballNumber.fromString(input)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("랜덤 생성된 숫자는 유효해야 한다.") + @Test + void random_valid() { + BaseballNumber number = BaseballNumber.random(); + assertThat(number).isNotNull(); + assertThat(number.size()).isEqualTo(3); + } +} diff --git a/src/test/java/baseball/model/GameCommandTest.java b/src/test/java/baseball/model/GameCommandTest.java new file mode 100644 index 00000000..9aa01c0d --- /dev/null +++ b/src/test/java/baseball/model/GameCommandTest.java @@ -0,0 +1,40 @@ +package baseball.model; + +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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GameCommandTest { + + @DisplayName("1을 입력하면 재시작(RESTART) 반환") + @Test + void restart() { + GameCommand command = GameCommand.from("1"); + assertThat(command).isEqualTo(GameCommand.RESTART); + } + + @DisplayName("2를 입력하면 종료(EXIT) 반환") + @Test + void exit() { + GameCommand command = GameCommand.from("2"); + assertThat(command).isEqualTo(GameCommand.EXIT); + } + + @DisplayName("공백이 있으면 삭제 후 처리") + @Test + void trim() { + assertThat(GameCommand.from(" 1 ")).isEqualTo(GameCommand.RESTART); + } + + @DisplayName("유효하지 않은 입력은 예외 발생") + @ParameterizedTest + @ValueSource(strings = {"3", "a", "", "0"}) + void invalid(String input) { + assertThatThrownBy(() -> GameCommand.from(input)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/baseball/model/ScoreTest.java b/src/test/java/baseball/model/ScoreTest.java new file mode 100644 index 00000000..3ee746bb --- /dev/null +++ b/src/test/java/baseball/model/ScoreTest.java @@ -0,0 +1,45 @@ +package baseball.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ScoreTest { + + @DisplayName("3스트라이크면 정답") + @Test + void isAllStrike() { + Score score = Score.of(3, 0, 3); + assertThat(score.isAllStrike()).isTrue(); + } + + @DisplayName("3스트라이크가 아니면 오답") + @Test + void isNotAllStrike() { + Score score = Score.of(2, 0, 3); + assertThat(score.isAllStrike()).isFalse(); + } + + @DisplayName("스트라이크와 볼이 모두 0이면 낫싱이다.") + @Test + void isNothing() { + Score score = Score.of(0, 0, 3); + assertThat(score.isNothing()).isTrue(); + } + + @DisplayName("스트라이크나 볼이 있으면 낫싱이 아니다.") + @Test + void isNotNothing() { + assertThat(Score.of(1, 0, 3).isNothing()).isFalse(); + assertThat(Score.of(0, 1, 3).isNothing()).isFalse(); + } + + @DisplayName("음수 값으로 생성하면 예외가 발생한다.") + @Test + void create_negative() { + assertThatThrownBy(() -> Score.of(-1, 0, 3)) + .isInstanceOf(IllegalArgumentException.class); + } +}