diff --git a/README.md b/README.md index 8d7e8aee..22791fa2 100644 --- a/README.md +++ b/README.md @@ -1 +1,177 @@ -# java-baseball-precourse \ No newline at end of file +# java-baseball-precourse + +## Commit 1. 컴퓨터 숫자 생성 규칙 구현 + +### 목표 + +컴퓨터가 사용하는 3자리 숫자의 생성 규칙을 도메인으로 분리한다. + +### 구현 내용 + +1~9 사이의 숫자 중 서로 다른 숫자 3개를 생성한다. + +생성된 숫자는 게임 도중 변경되지 않는다. + +랜덤 생성 로직은 별도의 생성 책임을 가지는 클래스로 분리한다. + +### 의도 + +숫자 생성 규칙을 UI 및 게임 흐름과 분리해 테스트 가능하도록 한다. + +## Commit 2. 판정 결과 모델(Hint) 구현 + +### 목표 + +스트라이크/볼 판정 결과를 표현하는 객체를 도입한다. + +### 구현 내용 + +strike, ball 값을 가진 불변 객체를 생성한다. + +3스트라이크 여부를 판단하는 책임을 가진다. + +스트라이크와 볼이 모두 0인 경우(낫싱)를 판단할 수 있다. + +### 의도 + +단순한 숫자 계산 결과를 의미 있는 도메인 객체로 표현한다. + +## Commit 3. 스트라이크/볼 판정 로직 구현 + +### 목표 + +컴퓨터 숫자와 사용자 입력을 비교하는 핵심 판정 로직을 구현한다. + +### 구현 내용 + +같은 숫자가 같은 자리에 있으면 스트라이크를 증가시킨다. + +같은 숫자가 다른 자리에 있으면 볼을 증가시킨다. + +판정 결과를 Hint 객체로 반환한다. + +### 의도 + +판정 로직을 Controller에서 분리하여 재사용성과 테스트 용이성을 확보한다. + +## Commit 4. 사용자 입력 파싱 및 검증 도메인 구현 + +### 목표 + +사용자 입력에 대한 모든 검증 로직을 도메인에서 처리한다. + +### 구현 내용 + +입력값은 문자열로 전달받아 도메인 객체로 변환한다. + +다음 조건을 만족하지 않으면 예외를 발생시킨다. + +길이가 3이 아닌 경우 + +숫자가 아닌 문자가 포함된 경우 + +1~9 범위를 벗어난 숫자가 있는 경우 (0 포함) + +중복된 숫자가 있는 경우 + +### 의도 + +“잘못된 입력”이라는 규칙을 UI나 Controller가 아닌 도메인에서 책임지도록 한다. + +## Commit 5. 도메인 단위 테스트 작성 + +### 목표 + +핵심 로직의 안정성을 단위 테스트로 검증한다. + +### 구현 내용 + +컴퓨터 숫자 생성 규칙 테스트 + +스트라이크/볼 판정 로직 테스트 + +사용자 입력 검증 테스트 + +JUnit5 + AssertJ 기반으로 테스트를 작성한다. + +UI 관련 코드는 테스트 대상에서 제외한다. + +### 의도 + +요구사항을 만족하는지 코드 레벨에서 명확히 검증한다. + +## Commit 6. 입출력(View) 구현 + +### 목표 + +사용자와의 입출력을 담당하는 View 계층을 구현한다. + +### 구현 내용 + +사용자 입력을 받는 기능을 분리한다. + +판정 결과를 요구사항에 맞는 형식으로 출력한다. + +잘못된 입력에 대해 [ERROR]로 시작하는 메시지를 출력한다. + +### 의도 + +출력 형식 변경이 도메인에 영향을 주지 않도록 한다. + +## Commit 7. 게임 진행 흐름(Controller) 구현 + +### 목표 + +전체 게임 흐름을 제어하는 Controller를 구현한다. + +### 구현 내용 + +숫자 입력 → 검증 → 판정 → 출력 과정을 반복한다. + +3스트라이크가 될 때까지 게임을 진행한다. + +도메인에서 발생한 예외를 처리해 게임이 종료되지 않도록 한다. + +### 의도 + +게임의 “흐름”과 “규칙”을 분리한다. + +## Commit 8. 게임 재시작 및 종료 처리 + +### 목표 + +게임 종료 후 재시작 또는 완전 종료 기능을 구현한다. + +### 구현 내용 + +게임 종료 후 선택지를 출력한다. + +1 입력 시 새로운 게임을 시작한다. + +2 입력 시 프로그램을 종료한다. + +잘못된 입력 시 예외를 발생시키고 다시 입력받는다. + +### 의도 + +한 번의 게임 로직을 재사용 가능한 구조로 만든다. + +## Commit 9. 제약사항 리팩토링 + +### 목표 + +모든 프로그래밍 요구사항을 최종적으로 만족시킨다. + +### 구현 내용 + +indent depth를 2 이하로 조정한다. + +else, switch/case 제거 + +메서드 길이 15라인 이하로 분리 + +하나의 메서드가 하나의 책임만 갖도록 리팩토링 + +### 의도 + +가독성과 유지보수성을 높인다. \ No newline at end of file diff --git a/src/main/java/baseball/Application.java b/src/main/java/baseball/Application.java new file mode 100644 index 00000000..a5b92948 --- /dev/null +++ b/src/main/java/baseball/Application.java @@ -0,0 +1,21 @@ +package baseball; + +import baseball.controller.GameController; +import baseball.domain.NumbersGenerator; +import baseball.domain.Umpire; +import baseball.view.InputView; +import baseball.view.OutputView; + +public class Application { + + public static void main(String[] args) { + GameController gameController = new GameController( + new NumbersGenerator(), + new Umpire(), + new InputView(), + new OutputView() + ); + + gameController.run(); + } +} diff --git a/src/main/java/baseball/controller/GameController.java b/src/main/java/baseball/controller/GameController.java new file mode 100644 index 00000000..d6d28a2f --- /dev/null +++ b/src/main/java/baseball/controller/GameController.java @@ -0,0 +1,91 @@ +package baseball.controller; + +import baseball.domain.Hint; +import baseball.domain.NumbersGenerator; +import baseball.domain.PlayerGuess; +import baseball.domain.Umpire; +import baseball.view.InputView; +import baseball.view.OutputView; + +public class GameController { + + private final NumbersGenerator numbersGenerator; + private final Umpire umpire; + private final InputView inputView; + private final OutputView outputView; + + public GameController(NumbersGenerator numbersGenerator, Umpire umpire, + InputView inputView, OutputView outputView) { + this.numbersGenerator = numbersGenerator; + this.umpire = umpire; + this.inputView = inputView; + this.outputView = outputView; + } + + public void run() { + while (true) { + playOneGame(); + + if (isRestart()) { + continue; + } + return; + } + } + + private void playOneGame() { + int[] answer = numbersGenerator.generate(); + + while (true) { + Hint hint = playOneTurn(answer); + + if (hint == null) { + continue; + } + + if (hint.isThreeStrike()) { + outputView.printGameEnd(); + return; + } + } + } + + private Hint playOneTurn(int[] answer) { + try { + PlayerGuess guess = readGuess(); + Hint hint = umpire.judge(answer, guess.asArray()); + outputView.printHint(hint); + return hint; + } catch (IllegalArgumentException e) { + outputView.printErrorMessage(); + return null; + } + } + + private PlayerGuess readGuess() { + String input = inputView.readGuess(); + return PlayerGuess.from(input); + } + + private boolean isRestart() { + try { + String input = inputView.readRestartCommand(); + return parseRestartCommand(input); + } catch (IllegalArgumentException e) { + outputView.printErrorMessage(); + return isRestart(); + } + } + + private boolean parseRestartCommand(String input) { + if ("1".equals(input)) { + return true; + } + + if ("2".equals(input)) { + return false; + } + + throw new IllegalArgumentException(); + } +} diff --git a/src/main/java/baseball/domain/Hint.java b/src/main/java/baseball/domain/Hint.java new file mode 100644 index 00000000..cfa6867c --- /dev/null +++ b/src/main/java/baseball/domain/Hint.java @@ -0,0 +1,28 @@ +package baseball.domain; + +public class Hint { + + private final int strike; + private final int ball; + + public Hint(int strike, int ball) { + this.strike = strike; + this.ball = ball; + } + + public int getStrike() { + return strike; + } + + public int getBall() { + return ball; + } + + public boolean isNothing() { + return strike == 0 && ball == 0; + } + + public boolean isThreeStrike() { + return strike == 3; + } +} diff --git a/src/main/java/baseball/domain/NumbersGenerator.java b/src/main/java/baseball/domain/NumbersGenerator.java new file mode 100644 index 00000000..ca22af3d --- /dev/null +++ b/src/main/java/baseball/domain/NumbersGenerator.java @@ -0,0 +1,31 @@ +package baseball.domain; + +public class NumbersGenerator { + + public int[] generate() { + int[] numbers = new int[3]; + for (int i = 0; i < 3; i++) { + int num = pickNumber(); + if (contains(numbers, i, num)) { + i--; + continue; + } + numbers[i] = num; + } + + return numbers; + } + + private int pickNumber() { + return (int) (Math.random() * 9) + 1; + } + + private boolean contains(int[] numbers, int size, int candidate) { + for (int i = 0; i < size; i++) { + if (numbers[i] == candidate) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/baseball/domain/PlayerGuess.java b/src/main/java/baseball/domain/PlayerGuess.java new file mode 100644 index 00000000..dd6b6372 --- /dev/null +++ b/src/main/java/baseball/domain/PlayerGuess.java @@ -0,0 +1,67 @@ +package baseball.domain; + +public class PlayerGuess { + + private final int[] numbers; + + private PlayerGuess(int[] numbers) { + this.numbers = numbers; + } + + public static PlayerGuess from(String input) { + validateLength(input); + validateNumeric(input); + + int[] numbers = parse(input); + + validateRange(numbers); + validateUnique(numbers); + + return new PlayerGuess(numbers); + } + + public int[] asArray() { + return numbers.clone(); + } + + private static int[] parse(String input) { + int[] numbers = new int[3]; + for (int i = 0; i < 3; i++) { + numbers[i] = input.charAt(i) - '0'; + } + return numbers; + } + + private static void validateLength(String input) { + if (input.length() != 3) { + throw new IllegalArgumentException("입력은 3자리여야 합니다."); + } + } + + private static void validateNumeric(String input) { + for (int i = 0; i < 3; i++) { + char c = input.charAt(i); + if (c < '0' || c > '9') { + throw new IllegalArgumentException("숫자만 입력해야 합니다."); + } + } + } + + private static void validateRange(int[] numbers) { + for (int n : numbers) { + if (n < 1 || n > 9) { + throw new IllegalArgumentException("숫자는 1부터 9 사이여야 합니다."); + } + } + } + + private static void validateUnique(int[] numbers) { + for (int i = 0; i < 3; i++) { + for (int j = i + 1; j < 3; j++) { + if (numbers[i] == numbers[j]) { + throw new IllegalArgumentException("중복된 숫자는 허용되지 않습니다."); + } + } + } + } +} diff --git a/src/main/java/baseball/domain/Umpire.java b/src/main/java/baseball/domain/Umpire.java new file mode 100644 index 00000000..9915585d --- /dev/null +++ b/src/main/java/baseball/domain/Umpire.java @@ -0,0 +1,39 @@ +package baseball.domain; + +public class Umpire { + + public Hint judge(int[] answer, int[] guess) { + int strike = countStrike(answer, guess); + int ball = countBall(answer, guess); + return new Hint(strike, ball); + } + + private int countStrike(int[] answer, int[] guess) { + int count = 0; + for (int i = 0; i < 3; i++) { + if (guess[i] == answer[i]) { + count++; + } + } + return count; + } + + private int countBall(int[] answer, int[] guess) { + int count = 0; + for (int i = 0; i < 3; i++) { + if (guess[i] != answer[i] && contains(answer, guess[i])) { + count++; + } + } + return count; + } + + private boolean contains(int[] numbers, int candidate) { + for (int n : numbers) { + if (candidate == n) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/baseball/view/InputView.java b/src/main/java/baseball/view/InputView.java new file mode 100644 index 00000000..f4b01619 --- /dev/null +++ b/src/main/java/baseball/view/InputView.java @@ -0,0 +1,18 @@ +package baseball.view; + +import java.util.Scanner; + +public class InputView { + + private static final Scanner SCANNER = new Scanner(System.in); + + public String readGuess() { + System.out.print("숫자를 입력해주세요 : "); + return SCANNER.nextLine(); + } + + public String readRestartCommand() { + System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요."); + return SCANNER.nextLine(); + } +} diff --git a/src/main/java/baseball/view/OutputView.java b/src/main/java/baseball/view/OutputView.java new file mode 100644 index 00000000..e3d4cf2a --- /dev/null +++ b/src/main/java/baseball/view/OutputView.java @@ -0,0 +1,46 @@ +package baseball.view; + +import baseball.domain.Hint; + +public class OutputView { + + public void printHint(Hint hint) { + if (hint.isNothing()) { + System.out.println("낫싱"); + return; + } + + System.out.println(buildHintMessage(hint)); + } + + private String buildHintMessage(Hint hint) { + StringBuilder sb = new StringBuilder(); + appendStrike(sb, hint); + appendBall(sb, hint); + return sb.toString(); + } + + private void appendStrike(StringBuilder sb, Hint hint) { + if (hint.getStrike() > 0) { + sb.append(hint.getStrike()).append("스트라이크"); + } + } + + private void appendBall(StringBuilder sb, Hint hint) { + if (hint.getBall() == 0) { + return; + } + if (sb.length() > 0) { + sb.append(" "); + } + sb.append(hint.getBall()).append("볼"); + } + + public void printErrorMessage() { + System.out.println("[ERROR] 잘못된 입력입니다."); + } + + public void printGameEnd() { + System.out.println("3개의 숫자를 모두 맞히셨습니다! 게임 끝"); + } +} diff --git a/src/test/java/baseball/domain/NumbersGeneratorTest.java b/src/test/java/baseball/domain/NumbersGeneratorTest.java new file mode 100644 index 00000000..b7113fb6 --- /dev/null +++ b/src/test/java/baseball/domain/NumbersGeneratorTest.java @@ -0,0 +1,40 @@ +package baseball.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class NumbersGeneratorTest { + + @Test + void generate_는_서로다른_3개의_숫자를_생성한다() { + NumbersGenerator generator = new NumbersGenerator(); + + int[] numbers = generator.generate(); + + assertThat(numbers).hasSize(3); + assertThat(numbers[0]).isBetween(1, 9); + assertThat(numbers[1]).isBetween(1, 9); + assertThat(numbers[2]).isBetween(1, 9); + assertThat(numbers[0]).isNotEqualTo(numbers[1]); + assertThat(numbers[0]).isNotEqualTo(numbers[2]); + assertThat(numbers[1]).isNotEqualTo(numbers[2]); + } + + @Test + void generate_는_여러번_호출해도_항상_규칙을_만족한다() { + NumbersGenerator generator = new NumbersGenerator(); + + for (int i = 0; i < 100; i++) { + int[] numbers = generator.generate(); + + assertThat(numbers).hasSize(3); + assertThat(numbers[0]).isBetween(1, 9); + assertThat(numbers[1]).isBetween(1, 9); + assertThat(numbers[2]).isBetween(1, 9); + assertThat(numbers[0]).isNotEqualTo(numbers[1]); + assertThat(numbers[0]).isNotEqualTo(numbers[2]); + assertThat(numbers[1]).isNotEqualTo(numbers[2]); + } + } +} diff --git a/src/test/java/baseball/domain/PlayerGuessTest.java b/src/test/java/baseball/domain/PlayerGuessTest.java new file mode 100644 index 00000000..6982f6c0 --- /dev/null +++ b/src/test/java/baseball/domain/PlayerGuessTest.java @@ -0,0 +1,40 @@ +package baseball.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class PlayerGuessTest { + + @Test + void 정상입력은_숫자배열로_변환된다() { + PlayerGuess guess = PlayerGuess.from("123"); + + assertThat(guess.asArray()).containsExactly(1, 2, 3); + } + + @Test + void 길이가_3이_아니면_예외() { + assertThatThrownBy(() -> PlayerGuess.from("12")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 숫자가_아닌_문자가_포함되면_예외() { + assertThatThrownBy(() -> PlayerGuess.from("1a3")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 숫자_0이_포함되면_예외() { + assertThatThrownBy(() -> PlayerGuess.from("102")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 중복된_숫자가_있으면_예외() { + assertThatThrownBy(() -> PlayerGuess.from("112")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/baseball/domain/UmpireTest.java b/src/test/java/baseball/domain/UmpireTest.java new file mode 100644 index 00000000..b8fa4bdd --- /dev/null +++ b/src/test/java/baseball/domain/UmpireTest.java @@ -0,0 +1,50 @@ +package baseball.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class UmpireTest { + + @Test + void 같은자리_같은숫자는_스트라이크다() { + Umpire umpire = new Umpire(); + + Hint hint = umpire.judge(new int[]{4, 2, 5}, new int[]{1, 2, 3}); + + assertThat(hint.getStrike()).isEqualTo(1); + assertThat(hint.getBall()).isEqualTo(0); + } + + @Test + void 다른자리_같은숫자는_볼이다() { + Umpire umpire = new Umpire(); + + Hint hint = umpire.judge(new int[]{4, 2, 5}, new int[]{4, 5, 6}); + + assertThat(hint.getStrike()).isEqualTo(1); + assertThat(hint.getBall()).isEqualTo(1); + } + + @Test + void 같은숫자가_전혀없으면_낫싱이다() { + Umpire umpire = new Umpire(); + + Hint hint = umpire.judge(new int[]{4, 2, 5}, new int[]{7, 8, 9}); + + assertThat(hint.getStrike()).isEqualTo(0); + assertThat(hint.getBall()).isEqualTo(0); + assertThat(hint.isNothing()).isTrue(); + } + + @Test + void 세자리_모두맞추면_3스트라이크다() { + Umpire umpire = new Umpire(); + + Hint hint = umpire.judge(new int[]{7, 1, 3}, new int[]{7, 1, 3}); + + assertThat(hint.isThreeStrike()).isTrue(); + assertThat(hint.getStrike()).isEqualTo(3); + assertThat(hint.getBall()).isEqualTo(0); + } +}