diff --git a/README.md b/README.md index e7af80b4ff..bc4f65d454 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,70 @@ -# 미션 - 숫자 야구 - -## 🔍 진행 방식 - -- 미션은 **기능 요구 사항, 프로그래밍 요구 사항, 과제 진행 요구 사항** 세 가지로 구성되어 있다. -- 세 개의 요구 사항을 만족하기 위해 노력한다. 특히 기능을 구현하기 전에 기능 목록을 만든다. -- 기능 요구 사항에 기재되지 않은 내용은 스스로 판단하여 구현한다. - -## 📮 미션 제출 방법 - -- 미션 구현을 완료한 후 GitHub을 통해 제출해야 한다. - - GitHub을 활용한 제출 방법은 [프리코스 과제 제출](https://github.com/woowacourse/woowacourse-docs/tree/master/precourse) 문서를 참고해 - 제출한다. -- GitHub에 미션을 제출한 후 [우아한테크코스 지원](https://apply.techcourse.co.kr) 사이트에 접속하여 프리코스 과제를 제출한다. - - 자세한 방법은 [제출 가이드](https://github.com/woowacourse/woowacourse-docs/tree/master/precourse#제출-가이드) 참고 - - **Pull Request만 보내고 지원 플랫폼에서 과제를 제출하지 않으면 최종 제출하지 않은 것으로 처리되니 주의한다.** - -## 🚨 과제 제출 전 체크 리스트 - 0점 방지 - -- 기능 구현을 모두 정상적으로 했더라도 **요구 사항에 명시된 출력값 형식을 지키지 않을 경우 0점으로 처리**한다. -- 기능 구현을 완료한 뒤 아래 가이드에 따라 테스트를 실행했을 때 모든 테스트가 성공하는지 확인한다. -- **테스트가 실패할 경우 0점으로 처리**되므로, 반드시 확인 후 제출한다. - -### 테스트 실행 가이드 - -- 터미널에서 `java -version`을 실행하여 Java 버전이 17인지 확인한다. - Eclipse 또는 IntelliJ IDEA와 같은 IDE에서 Java 17로 실행되는지 확인한다. -- 터미널에서 Mac 또는 Linux 사용자의 경우 `./gradlew clean test` 명령을 실행하고, - Windows 사용자의 경우 `gradlew.bat clean test` 또는 `./gradlew.bat clean test` 명령을 실행할 때 모든 테스트가 아래와 같이 통과하는지 확인한다. - -``` -BUILD SUCCESSFUL in 0s -``` +우아한테크코스 웹 백엔드 최종 코딩테스트 대비를 위한 연습용 프로젝트: 숫자 야구 --- -## 🚀 기능 요구 사항 - -기본적으로 1부터 9까지 서로 다른 수로 이루어진 3자리의 수를 맞추는 게임이다. - -- 같은 수가 같은 자리에 있으면 스트라이크, 다른 자리에 있으면 볼, 같은 수가 전혀 없으면 낫싱이란 힌트를 얻고, 그 힌트를 이용해서 먼저 상대방(컴퓨터)의 수를 맞추면 승리한다. - - 예) 상대방(컴퓨터)의 수가 425일 때 - - 123을 제시한 경우 : 1스트라이크 - - 456을 제시한 경우 : 1볼 1스트라이크 - - 789를 제시한 경우 : 낫싱 -- 위 숫자 야구 게임에서 상대방의 역할을 컴퓨터가 한다. 컴퓨터는 1에서 9까지 서로 다른 임의의 수 3개를 선택한다. 게임 플레이어는 컴퓨터가 생각하고 있는 서로 다른 3개의 숫자를 입력하고, 컴퓨터는 입력한 숫자에 대한 - 결과를 출력한다. -- 이 같은 과정을 반복해 컴퓨터가 선택한 3개의 숫자를 모두 맞히면 게임이 종료된다. -- 게임을 종료한 후 게임을 다시 시작하거나 완전히 종료할 수 있다. -- 사용자가 잘못된 값을 입력할 경우 `IllegalArgumentException`을 발생시킨 후 애플리케이션은 종료되어야 한다. - -### 입출력 요구 사항 - -#### 입력 - -- 서로 다른 3자리의 수 -- 게임이 끝난 경우 재시작/종료를 구분하는 1과 2 중 하나의 수 - -#### 출력 - -- 입력한 수에 대한 결과를 볼, 스트라이크 개수로 표시 - -``` -1볼 1스트라이크 -``` - -- 하나도 없는 경우 - -``` -낫싱 -``` +## 🎯 프로젝트 개요 -- 3개의 숫자를 모두 맞힐 경우 +우아한테크코스 최종 코딩테스트를 대비하기 위한 연습용 프로젝트입니다. +숫자 야구 게임 프로그램을 구현합니다. -``` -3스트라이크 -3개의 숫자를 모두 맞히셨습니다! 게임 종료 -``` +--- -- 게임 시작 문구 출력 +## 📝 구현 기능 목록 + +### 1. 게임 시작 +- [x] **게임 시작 안내** + - 시작 안내 문구 출력 (형식: `숫자 야구 게임을 시작합니다.`) +- [x] **정답 숫자 설정** + - 1 ~ 9 중 서로 다른 랜덤한 숫자 3개 선정 + - 선정된 정답 숫자 저장 + +### 2. 게임 진행 +- [x] **정답 후보 숫자 입력** + - 입력 안내 문구 출력 (형식: `숫자를 입력해주세요 : `) + - 사용자가 정답으로 유추되는 숫자를 입력 + - 1 ~ 9 사이 세 자리의 겹치지 않는 숫자 입력 + - **(예외 처리)** 사용자 정답 유효성 검사: `IllegalArgumentException` (형식: `[ERROR] {에러메시지 내용}`) + - 입력 값이 존재하지 않는 경우: 빈 문자열, 공백 문자열, Null + - 숫자로 변환이 불가한 경우: 숫자가 아닌 문자 포함 + - 입력한 숫자가 세 자리가 아닌 경우: 입력한 숫자의 개수가 3개 미만 또는 초과 + - 입력한 숫자가 1 ~ 9 의 양수가 아닌 경우: 입력한 숫자가 1 ~ 9 범위에서 벗어남 + - 입력한 숫자 중 중복되는 숫자가 존재하는 경우: 세 숫자 중 겹치는 숫자가 존재함 + - 예외 발생 시, 게임 종료 + - 사용자가 입력한 정답 후보 숫자 저장 +- [x] **일치하는 숫자 결과 확인** + - 같은 수가 존재하는 경우 + - 같은 수가 같은 자리에 있는 경우 카운트 + - 같은 수가 다른 자리에 있는 경우 카운트 +- [x] **정답 입력 시도 결과 출력** + - 일치 여부 결과 출력 + - 같은 수가 존재하는 경우 (형식: `{일치 개수}{경우의 종류}`, 여러 개라면 한칸 띄우고 이어서 작성) + - 같은 수가 같은 자리에 있는 경우: 스트라이크 + - 같은 수가 다른 자리에 있는 경우: 볼 + - 같은 수가 전혀 없는 경우 (형식: `낫싱`) + - 정답을 맞추지 못한 경우 게임 진행 프로세스 다시 시작 + - 정답을 맞춘 경우, 게임 종료 프로세스 진행 + +### 3. 게임 종료 +- [x] **게임 종료 안내 출력** (형식: `3개의 숫자를 모두 맞히셨습니다! 게임 종료`) + - 3스트라이크로 3개의 숫자를 모두 맞춘 경우 게임 종료 +- [x] **새 게임 진행 여부 결정** + - 게임 진행 안내 문구 출력 (형식: `게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.`) + - 사용자가 게임의 진행을 결정하는 숫자를 입력 + - 1 또는 2를 입력 + - **(예외 처리)** 사용자 답변 유효성 검사: `IllegalArgumentException` (형식: `[ERROR] {에러메시지 내용}`) + - 입력 값이 존재하지 않는 경우: 빈 문자열, 공백 문자열, Null + - 숫자로 변환이 불가한 경우: 숫자가 아닌 문자 포함 + - 입력한 숫자가 1 또는 2가 아닌 경우: 입력한 숫자가 1, 2 이외의 숫자인 경우 + - 예외 발생 시, 게임 종료 + - 사용자가 입력한 숫자에 따라 새 게임 진행 여부 판별 + - 입력한 숫자가 1일 경우: 게임 시작 프로세스 다시 시작. 게임 시작 안내 문구는 생략. + - 입력한 숫자가 2일 경우: 게임 종료 -``` -숫자 야구 게임을 시작합니다. -``` +--- -#### 실행 결과 예시 +## 🖨️ 실행 결과 예시 -``` +```prolog 숫자 야구 게임을 시작합니다. 숫자를 입력해주세요 : 123 1볼 1스트라이크 @@ -104,41 +83,3 @@ BUILD SUCCESSFUL in 0s 1볼 ... ``` - ---- - -## 🎯 프로그래밍 요구 사항 - -- JDK 17 버전에서 실행 가능해야 한다. **JDK 17에서 정상적으로 동작하지 않을 경우 0점 처리한다.** -- 프로그램 실행의 시작점은 `Application`의 `main()`이다. -- `build.gradle` 파일을 변경할 수 없고, 외부 라이브러리를 사용하지 않는다. -- [Java 코드 컨벤션](https://github.com/woowacourse/woowacourse-docs/tree/master/styleguide/java) 가이드를 준수하며 프로그래밍한다. -- 프로그램 종료 시 `System.exit()`를 호출하지 않는다. -- 프로그램 구현이 완료되면 `ApplicationTest`의 모든 테스트가 성공해야 한다. **테스트가 실패할 경우 0점 처리한다.** -- 프로그래밍 요구 사항에서 달리 명시하지 않는 한 파일, 패키지 이름을 수정하거나 이동하지 않는다. - -### 라이브러리 - -- `camp.nextstep.edu.missionutils`에서 제공하는 `Randoms` 및 `Console` API를 사용하여 구현해야 한다. - - Random 값 추출은 `camp.nextstep.edu.missionutils.Randoms`의 `pickNumberInRange()`를 활용한다. - - 사용자가 입력하는 값은 `camp.nextstep.edu.missionutils.Console`의 `readLine()`을 활용한다. - -#### 사용 예시 - -```java -List computer = new ArrayList<>(); -while (computer.size() < 3) { - int randomNumber = Randoms.pickNumberInRange(1, 9); - if (!computer.contains(randomNumber)) { - computer.add(randomNumber); - } -} -``` - ---- - -## ✏️ 과제 진행 요구 사항 - -- 미션은 [java-baseball-6](https://github.com/woowacourse-precourse/java-baseball-6) 저장소를 Fork & Clone해 시작한다. -- **기능을 구현하기 전 `docs/README.md`에 구현할 기능 목록을 정리**해 추가한다. -- 과제 진행 및 제출 방법은 [프리코스 과제 제출](https://github.com/woowacourse/woowacourse-docs/tree/master/precourse) 문서를 참고한다. diff --git a/src/main/java/baseball/Application.java b/src/main/java/baseball/Application.java index dd95a34214..41de07fe59 100644 --- a/src/main/java/baseball/Application.java +++ b/src/main/java/baseball/Application.java @@ -1,7 +1,16 @@ package baseball; +import baseball.config.AppConfig; +import baseball.controller.GameController; + +/** + * 프로그램 진입점을 담당하는 클래스 + */ public class Application { public static void main(String[] args) { - // TODO: 프로그램 구현 + AppConfig appConfig = AppConfig.getInstance(); + + GameController controller = appConfig.GameController(); + controller.run(); } } diff --git a/src/main/java/baseball/config/AppConfig.java b/src/main/java/baseball/config/AppConfig.java new file mode 100644 index 0000000000..a7f8acc8e4 --- /dev/null +++ b/src/main/java/baseball/config/AppConfig.java @@ -0,0 +1,37 @@ +package baseball.config; + +import baseball.controller.GameController; +import baseball.service.BaseballService; +import baseball.util.NumberGenerator; +import baseball.util.RandomNumberGenerator; +import baseball.view.ConsoleInputView; +import baseball.view.ConsoleOutputView; +import baseball.view.InputView; +import baseball.view.OutputView; + +/** + * 애플리케이션의 실행에 필요한 모든 객체를 생성하고 서로 연결하는 설정 클래스 + */ +public class AppConfig { + private static class LazyHolder { + public static final AppConfig INSTANCE = new AppConfig(); + + public static final InputView INPUT_VIEW = new ConsoleInputView(); + public static final OutputView OUTPUT_VIEW = new ConsoleOutputView(); + + public static final NumberGenerator NUMBER_GENERATOR = new RandomNumberGenerator(); + public static final BaseballService SERVICE = new BaseballService(NUMBER_GENERATOR); + + public static final GameController CONTROLLER = new GameController(INPUT_VIEW, OUTPUT_VIEW, SERVICE); + } + + private AppConfig() {} + + public static AppConfig getInstance() { + return LazyHolder.INSTANCE; + } + + public GameController GameController() { + return LazyHolder.CONTROLLER; + } +} diff --git a/src/main/java/baseball/controller/GameController.java b/src/main/java/baseball/controller/GameController.java new file mode 100644 index 0000000000..8827797948 --- /dev/null +++ b/src/main/java/baseball/controller/GameController.java @@ -0,0 +1,61 @@ +package baseball.controller; + +import baseball.domain.Game; +import baseball.domain.Matches; +import baseball.exception.NoMatchResultException; +import baseball.service.BaseballService; +import baseball.view.InputView; +import baseball.view.OutputView; + +/** + * 프로그램의 전체 흐름을 조율하는 클래스 + */ +public class GameController { + private final InputView inputView; + private final OutputView outputView; + private final BaseballService service; + + public GameController(InputView inputView, OutputView outputView, BaseballService service) { + this.inputView = inputView; + this.outputView = outputView; + this.service = service; + }; + + public void run() { + outputView.printGameStartInstruction(); + boolean flag = false; + do { + startGame(); + outputView.printGameEndInstruction(); + flag = isStartingNewGame(); + } while (flag); + } + + private void startGame() { + Game game = service.createGame(); + boolean flag = false; + do { + boolean isGameOver = guess(game); + flag = !isGameOver; + } while (flag); + } + + private boolean guess(Game game) { + outputView.printNumberInputPrompt(); + String guessNumber = inputView.readGuessingNumber(); + try { + Matches matchResult = service.match(game, guessNumber); + outputView.printMatchResults(matchResult); + return service.isGameOver(matchResult); + } catch (NoMatchResultException e) { + outputView.printMatchResults(); + } + return false; + } + + private boolean isStartingNewGame() { + outputView.printGameProceedPrompt(); + String gameProceed = inputView.readGameProceed(); + return service.isStartingNewGame(gameProceed); + } +} diff --git a/src/main/java/baseball/domain/BaseballNumber.java b/src/main/java/baseball/domain/BaseballNumber.java new file mode 100644 index 0000000000..55010a817d --- /dev/null +++ b/src/main/java/baseball/domain/BaseballNumber.java @@ -0,0 +1,94 @@ +package baseball.domain; + +import baseball.exception.ErrorMessage; +import baseball.exception.InputNotNumericException; +import baseball.exception.InputNullOrBlankException; +import baseball.exception.InputNumberOverflowException; +import baseball.util.InputParser; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 숫자 야구 게임에 사용되는 숫자 도메인 클래스 + */ +public class BaseballNumber { + public static int NUMBER_SIZE = 3; + public static int NUMBER_MIN = 1; + public static int NUMBER_MAX = 9; + + private final List numbers; + + public BaseballNumber(List numbers) { + validate(numbers); + this.numbers = numbers; + } + + public static BaseballNumber from(String input) { + List numbers = new ArrayList<>(); + for (String numberString : input.split("")) { + try { + numbers.add(InputParser.parseToInt(numberString)); + } catch (InputNullOrBlankException e) { + throw new IllegalArgumentException(ErrorMessage.NUMBER_NULL_OR_BLANK.getMessage()); + } catch (InputNotNumericException e) { + throw new IllegalArgumentException(ErrorMessage.NUMBER_NOT_NUMERIC.getMessage()); + } catch (InputNumberOverflowException e) { + throw new IllegalArgumentException(ErrorMessage.INVALID_NUMBER_SIZE.getMessage()); + } + } + return new BaseballNumber(numbers); + } + + public List match(BaseballNumber guess) { + List matches = new ArrayList<>(); + for (int i = 0; i < NUMBER_SIZE; i++) { + int number = numbers.get(i); + boolean hasMatchingNumber = guess.contains(number); + boolean hasCorrectMatchIndex = guess.get(i) == number; + if (hasMatchingNumber) { + matches.add(Match.of(hasMatchingNumber, hasCorrectMatchIndex)); + } + } + return matches; + } + + private boolean contains(int number) { + return numbers.contains(number); + } + + private int get(int index) { + return numbers.get(index); + } + + private void validate(List numbers) { + validateSize(numbers); + validateRange(numbers); + validateDuplication(numbers); + } + + private void validateSize(List numbers) { + if (numbers.size() != 3) { + throw new IllegalArgumentException(ErrorMessage.INVALID_NUMBER_SIZE.getMessage()); + } + } + + private void validateRange(List numbers) { + for (int number : numbers) { + if (number < NUMBER_MIN || number > NUMBER_MAX) { + throw new IllegalArgumentException(ErrorMessage.NUMBER_OUT_OF_RANGE.getMessage()); + } + } + } + + private void validateDuplication(List numbers) { + Set uniqueNumbers = new HashSet<>(); + for (int number : numbers) { + if (!uniqueNumbers.add(number)) { + throw new IllegalArgumentException(ErrorMessage.NUMBER_DUPLICATED.getMessage()); + } + } + } +} diff --git a/src/main/java/baseball/domain/Game.java b/src/main/java/baseball/domain/Game.java new file mode 100644 index 0000000000..963195883a --- /dev/null +++ b/src/main/java/baseball/domain/Game.java @@ -0,0 +1,18 @@ +package baseball.domain; + +import java.util.List; + +/** + * 게임 상태 관리를 담당하는 클래스 + */ +public class Game { + private final BaseballNumber answer; + + public Game(BaseballNumber answer) { + this.answer = answer; + } + + public List matchWithAnswer(BaseballNumber number) { + return answer.match(number); + } +} diff --git a/src/main/java/baseball/domain/Match.java b/src/main/java/baseball/domain/Match.java new file mode 100644 index 0000000000..9e5597a2a8 --- /dev/null +++ b/src/main/java/baseball/domain/Match.java @@ -0,0 +1,30 @@ +package baseball.domain; + +import baseball.exception.NoMatchingNumberException; + +public enum Match { + BALL("볼"), + STRIKE("스트라이크"); + + private final String message; + + Match(String message) { + this.message = message; + } + + public static Match of(boolean hasMatchingNumber, boolean hasCorrectMatchIndex) { + if (hasMatchingNumber && hasCorrectMatchIndex) { + return STRIKE; + } + + if (hasMatchingNumber) { + return BALL; + } + + throw new NoMatchingNumberException(); + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/baseball/domain/Matches.java b/src/main/java/baseball/domain/Matches.java new file mode 100644 index 0000000000..e7a7b02f19 --- /dev/null +++ b/src/main/java/baseball/domain/Matches.java @@ -0,0 +1,24 @@ +package baseball.domain; + +import java.util.EnumMap; +import java.util.List; + +public class Matches { + private final EnumMap matchCount; + + public Matches(EnumMap matchCount) { + this.matchCount = matchCount; + } + + public static Matches from(List matches) { + EnumMap matchCount = new EnumMap<>(Match.class); + for (Match match : matches) { + matchCount.merge(match, 1, Integer::sum); + } + return new Matches(matchCount); + } + + public int getMatchCount(Match match) { + return matchCount.getOrDefault(match, 0); + } +} diff --git a/src/main/java/baseball/exception/ErrorMessage.java b/src/main/java/baseball/exception/ErrorMessage.java new file mode 100644 index 0000000000..b324204e13 --- /dev/null +++ b/src/main/java/baseball/exception/ErrorMessage.java @@ -0,0 +1,28 @@ +package baseball.exception; + +/** + * 오류 메시지를 정의한 enum 클래스 + */ +public enum ErrorMessage { + // BaseballNumber Error + INVALID_NUMBER_SIZE("숫자는 세자리여야 합니다."), + NUMBER_OUT_OF_RANGE("각 자리의 숫자는 1~9 사이의 정수여야 합니다."), + NUMBER_NULL_OR_BLANK("숫자는 비워두거나 공백을 포함할 수 없습니다."), + NUMBER_NOT_NUMERIC("숫자가 아닌 문자를 포함할 수 없습니다."), + NUMBER_DUPLICATED("세자리의 숫자는 서로 중복될 수 없습니다."), + + // BaseballService Error + INVALID_ANSWER("1 또는 2로 답변해야 합니다."); + + private static final String ERROR_PREFIX = "[ERROR] "; + + private final String message; + + ErrorMessage(String message) { + this.message = message; + } + + public String getMessage() { + return ERROR_PREFIX + message; + } +} diff --git a/src/main/java/baseball/exception/InputNotNumericException.java b/src/main/java/baseball/exception/InputNotNumericException.java new file mode 100644 index 0000000000..56bea0d75c --- /dev/null +++ b/src/main/java/baseball/exception/InputNotNumericException.java @@ -0,0 +1,10 @@ +package baseball.exception; + +/** + * 입력값이 숫자가 아닐 경우 발생하는 예외 클래스 + */ +public class InputNotNumericException extends RuntimeException { + public InputNotNumericException() { + super(); + } +} diff --git a/src/main/java/baseball/exception/InputNullOrBlankException.java b/src/main/java/baseball/exception/InputNullOrBlankException.java new file mode 100644 index 0000000000..fe8b0a1e43 --- /dev/null +++ b/src/main/java/baseball/exception/InputNullOrBlankException.java @@ -0,0 +1,10 @@ +package baseball.exception; + +/** + * 입력값이 null이거나, 비어 있거나, 공백 문자로만 이루어진 경우 발생하는 예외 클래스 + */ +public class InputNullOrBlankException extends RuntimeException { + public InputNullOrBlankException() { + super(); + } +} diff --git a/src/main/java/baseball/exception/InputNumberOverflowException.java b/src/main/java/baseball/exception/InputNumberOverflowException.java new file mode 100644 index 0000000000..b72e645892 --- /dev/null +++ b/src/main/java/baseball/exception/InputNumberOverflowException.java @@ -0,0 +1,10 @@ +package baseball.exception; + +/** + * 입력한 값이 유효한 범위를 초과한 경우 발생하는 예외 클래스 + */ +public class InputNumberOverflowException extends RuntimeException { + public InputNumberOverflowException() { + super(); + } +} diff --git a/src/main/java/baseball/exception/NoMatchResultException.java b/src/main/java/baseball/exception/NoMatchResultException.java new file mode 100644 index 0000000000..cd2cde9bb9 --- /dev/null +++ b/src/main/java/baseball/exception/NoMatchResultException.java @@ -0,0 +1,7 @@ +package baseball.exception; + +public class NoMatchResultException extends RuntimeException { + public NoMatchResultException() { + super(); + } +} diff --git a/src/main/java/baseball/exception/NoMatchingNumberException.java b/src/main/java/baseball/exception/NoMatchingNumberException.java new file mode 100644 index 0000000000..508e66509f --- /dev/null +++ b/src/main/java/baseball/exception/NoMatchingNumberException.java @@ -0,0 +1,7 @@ +package baseball.exception; + +public class NoMatchingNumberException extends RuntimeException { + public NoMatchingNumberException() { + super(); + } +} diff --git a/src/main/java/baseball/service/BaseballService.java b/src/main/java/baseball/service/BaseballService.java new file mode 100644 index 0000000000..caa6485815 --- /dev/null +++ b/src/main/java/baseball/service/BaseballService.java @@ -0,0 +1,61 @@ +package baseball.service; + +import baseball.domain.Game; +import baseball.domain.BaseballNumber; +import baseball.domain.Match; +import baseball.domain.Matches; +import baseball.exception.*; +import baseball.util.InputParser; +import baseball.util.NumberGenerator; + +import java.util.List; + +import static baseball.domain.BaseballNumber.NUMBER_SIZE; +import static baseball.domain.BaseballNumber.NUMBER_MIN; +import static baseball.domain.BaseballNumber.NUMBER_MAX; + +/** + * 숫자 야구 게임 비즈니스 로직을 담당하는 클래스 + */ +public class BaseballService { + private final NumberGenerator numberGenerator; + + public BaseballService(NumberGenerator numberGenerator) { + this.numberGenerator = numberGenerator; + } + + public Game createGame() { + List numbers = numberGenerator.generateUniqueNumbersInRange(NUMBER_SIZE, NUMBER_MIN, NUMBER_MAX); + BaseballNumber answer = new BaseballNumber(numbers); + return new Game(answer); + } + + public Matches match(Game game, String guessString) { + BaseballNumber guessNumber = BaseballNumber.from(guessString); + List matches = game.matchWithAnswer(guessNumber); + if (matches.isEmpty()) { + throw new NoMatchResultException(); + } + return Matches.from(matches); + } + + public boolean isGameOver(Matches matches) { + return matches.getMatchCount(Match.STRIKE) == NUMBER_SIZE; + } + + public boolean isStartingNewGame(String gameProceed) { + validateAnswer(gameProceed); + return gameProceed.equals("1"); + } + + private void validateAnswer(String gameProceed) { + try { + int answer = InputParser.parseToInt(gameProceed); + if (answer < 1 || answer > 2) { + throw new IllegalArgumentException(ErrorMessage.INVALID_ANSWER.getMessage()); + } + } catch (InputNullOrBlankException | InputNotNumericException | InputNumberOverflowException e) { + throw new IllegalArgumentException(ErrorMessage.INVALID_ANSWER.getMessage()); + } + } +} diff --git a/src/main/java/baseball/util/InputParser.java b/src/main/java/baseball/util/InputParser.java new file mode 100644 index 0000000000..44637ee91f --- /dev/null +++ b/src/main/java/baseball/util/InputParser.java @@ -0,0 +1,42 @@ +package baseball.util; + +import baseball.exception.InputNotNumericException; +import baseball.exception.InputNullOrBlankException; +import baseball.exception.InputNumberOverflowException; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; + +/** + * 사용자 입력을 변환, 검증하는 유틸리티 클래스 + */ +public final class InputParser { + private InputParser() {} + + public static String refineInput(String input) { + boolean isNullOrBlank = (input == null) || input.isBlank(); + if (isNullOrBlank) { + throw new InputNullOrBlankException(); + } + return input.trim(); + } + + public static int parseToInt(String input) { + String refinedInput = refineInput(input); + try { + return Integer.parseInt(refinedInput); + } catch (NumberFormatException e) { + distinguishNumberFormatError(input); + throw new InputNumberOverflowException(); + } + } + + private static void distinguishNumberFormatError(String input) { + try { + new BigInteger(input); + } catch (NumberFormatException e) { + throw new InputNotNumericException(); + } + } +} \ No newline at end of file diff --git a/src/main/java/baseball/util/NumberGenerator.java b/src/main/java/baseball/util/NumberGenerator.java new file mode 100644 index 0000000000..04d3323249 --- /dev/null +++ b/src/main/java/baseball/util/NumberGenerator.java @@ -0,0 +1,7 @@ +package baseball.util; + +import java.util.List; + +public interface NumberGenerator { + List generateUniqueNumbersInRange(int size, int start, int end); +} diff --git a/src/main/java/baseball/util/RandomNumberGenerator.java b/src/main/java/baseball/util/RandomNumberGenerator.java new file mode 100644 index 0000000000..3a1e1db507 --- /dev/null +++ b/src/main/java/baseball/util/RandomNumberGenerator.java @@ -0,0 +1,19 @@ +package baseball.util; + +import camp.nextstep.edu.missionutils.Randoms; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class RandomNumberGenerator implements NumberGenerator { + @Override + public List generateUniqueNumbersInRange(int size, int start, int end) { + Set numbers = new HashSet<>(); + while (numbers.size() < size) { + numbers.add(Randoms.pickNumberInRange(start, end)); + } + + return numbers.stream().toList(); + } +} diff --git a/src/main/java/baseball/view/ConsoleInputView.java b/src/main/java/baseball/view/ConsoleInputView.java new file mode 100644 index 0000000000..ac5035ea14 --- /dev/null +++ b/src/main/java/baseball/view/ConsoleInputView.java @@ -0,0 +1,18 @@ +package baseball.view; + +import camp.nextstep.edu.missionutils.Console; + +/** + * 프로그램의 콘솔 입력을 담당하는 클래스 + */ +public class ConsoleInputView implements InputView { + @Override + public String readGuessingNumber() { + return Console.readLine(); + } + + @Override + public String readGameProceed() { + return Console.readLine(); + } +} \ No newline at end of file diff --git a/src/main/java/baseball/view/ConsoleOutputView.java b/src/main/java/baseball/view/ConsoleOutputView.java new file mode 100644 index 0000000000..7e12fe5db0 --- /dev/null +++ b/src/main/java/baseball/view/ConsoleOutputView.java @@ -0,0 +1,49 @@ +package baseball.view; + +import baseball.domain.Match; +import baseball.domain.Matches; + +/** + * 프로그램의 콘솔 출력을 담당하는 클래스 + */ +public class ConsoleOutputView implements OutputView { + @Override + public void printGameStartInstruction() { + System.out.println("숫자 야구 게임을 시작합니다."); + }; + + @Override + public void printNumberInputPrompt() { + System.out.print("숫자를 입력해주세요 : "); + } + + @Override + public void printMatchResults() { + System.out.println("낫싱"); + } + + @Override + public void printMatchResults(Matches matches) { + for (Match match : Match.values()) { + int count = matches.getMatchCount(match); + if (count > 0) { + printMatchResult(match, count); + } + } + System.out.println(); + } + + @Override + public void printGameEndInstruction() { + System.out.println("3개의 숫자를 모두 맞히셨습니다! 게임 종료"); + }; + + @Override + public void printGameProceedPrompt() { + System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요."); + } + + private void printMatchResult(Match match, int count) { + System.out.printf("%d%s ", count, match.getMessage()); + } +} diff --git a/src/main/java/baseball/view/InputView.java b/src/main/java/baseball/view/InputView.java new file mode 100644 index 0000000000..0e41b341e9 --- /dev/null +++ b/src/main/java/baseball/view/InputView.java @@ -0,0 +1,14 @@ +package baseball.view; + +/** + * 프로그램의 모든 입력을 담당하는 인터페이스 + */ +public interface InputView { + default String readGuessingNumber() { + return null; + }; + + default String readGameProceed() { + return null; + }; +} \ No newline at end of file diff --git a/src/main/java/baseball/view/OutputView.java b/src/main/java/baseball/view/OutputView.java new file mode 100644 index 0000000000..0ec53c200a --- /dev/null +++ b/src/main/java/baseball/view/OutputView.java @@ -0,0 +1,20 @@ +package baseball.view; + +import baseball.domain.Matches; + +/** + * 프로그램의 모든 출력을 담당하는 인터페이스 + */ +public interface OutputView { + default void printGameStartInstruction() {}; + + default void printNumberInputPrompt() {}; + + default void printMatchResults() {}; + + default void printMatchResults(Matches matchResult) {}; + + default void printGameEndInstruction() {}; + + default void printGameProceedPrompt() {}; +} \ No newline at end of file diff --git a/src/test/java/baseball/domain/BaseballNumberTest.java b/src/test/java/baseball/domain/BaseballNumberTest.java new file mode 100644 index 0000000000..fc07d77d5b --- /dev/null +++ b/src/test/java/baseball/domain/BaseballNumberTest.java @@ -0,0 +1,89 @@ +package baseball.domain; + +import baseball.exception.ErrorMessage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class BaseballNumberTest { + @Nested + class SuccessTest { + @DisplayName("유효한 숫자가 주어지면 정상적으로 BaseballNumber 객체를 생성한다") + @Test + void should_Return_BaseballNumber() { + // when & then + assertThat(new BaseballNumber(List.of(1,2,3))).isNotNull() + .isInstanceOf(BaseballNumber.class); + assertThat(BaseballNumber.from("123")).isNotNull() + .isInstanceOf(BaseballNumber.class); + } + } + + @Nested + class exceptionTest { + @DisplayName("숫자가 비어있거나 공백을 포함하는 경우 예외를 발생시킨다") + @ParameterizedTest + @ValueSource(strings = {" ", "", "1 23"}) + void should_ThrowException_WhenNumberNullOrBlank(String input) { + // when & then + assertThatThrownBy(() -> BaseballNumber.from(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ErrorMessage.NUMBER_NULL_OR_BLANK.getMessage()); + } + + @DisplayName("숫자가 아닌 문자를 포함하는 경우 예외를 발생시킨다") + @ParameterizedTest + @ValueSource(strings = {"1d5", "g23", "123a"}) + void should_ThrowException_WhenNumberNotNumeric(String input) { + // when & then + assertThatThrownBy(() -> BaseballNumber.from(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ErrorMessage.NUMBER_NOT_NUMERIC.getMessage()); + } + + @DisplayName("유효하지 않은 개수의 숫자가 주어지면 예외를 발생시킨다") + @Test + void should_ThrowException_ForInvalidNumber() { + // when & then + assertThatThrownBy(() -> new BaseballNumber(List.of(1,2,3,4))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ErrorMessage.INVALID_NUMBER_SIZE.getMessage()); + + assertThatThrownBy(() -> BaseballNumber.from("1234765")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ErrorMessage.INVALID_NUMBER_SIZE.getMessage()); + } + + @DisplayName("유효하지 않은 범위의 숫자가 주어지면 예외를 발생시킨다") + @Test + void should_ThrowException_ForNumberOutOfRange() { + // when & then + assertThatThrownBy(() -> new BaseballNumber(List.of(0,1,2))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ErrorMessage.NUMBER_OUT_OF_RANGE.getMessage()); + + assertThatThrownBy(() -> BaseballNumber.from("012")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ErrorMessage.NUMBER_OUT_OF_RANGE.getMessage()); + } + + @DisplayName("중복된 숫자가 포함된 경우 예외를 발생시킨다") + @Test + void should_ThrowException_ForDuplicatedNumbers() { + // when & then + assertThatThrownBy(() -> new BaseballNumber(List.of(1,1,2))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ErrorMessage.NUMBER_DUPLICATED.getMessage()); + assertThatThrownBy(() -> BaseballNumber.from("112")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ErrorMessage.NUMBER_DUPLICATED.getMessage()); + } + } +} diff --git a/src/test/java/baseball/service/BaseballServiceTest.java b/src/test/java/baseball/service/BaseballServiceTest.java new file mode 100644 index 0000000000..76ac224c37 --- /dev/null +++ b/src/test/java/baseball/service/BaseballServiceTest.java @@ -0,0 +1,55 @@ +package baseball.service; + +import baseball.domain.BaseballNumber; +import baseball.domain.Game; +import baseball.domain.Match; +import baseball.domain.Matches; +import baseball.util.NumberGenerator; +import baseball.util.RandomNumberGenerator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class BaseballServiceTest { + @Nested + class SuccessTest { + @DisplayName("게임을 정상적으로 생성한다") + @Test + void should_Return_Game() { + // given + NumberGenerator numberGenerator = new RandomNumberGenerator(); + BaseballService service = new BaseballService(numberGenerator); + + // when & then + assertThat(service.createGame()).isNotNull() + .isInstanceOf(Game.class); + } + + @DisplayName("게임 결과를 계산한다") + @Test + void should_Return_Matches() { + // given + BaseballNumber answer = BaseballNumber.from("123"); + Game game = new Game(answer); + NumberGenerator numberGenerator = new NumberGenerator() { + @Override + public List generateUniqueNumbersInRange(int size, int start, int end) { + return List.of(1,2,3); + } + }; + BaseballService service = new BaseballService(numberGenerator); + + // when + Matches matches = service.match(game, "123"); + + // then + assertThat(matches.getMatchCount(Match.STRIKE)).isEqualTo(3); + assertThat(matches.getMatchCount(Match.BALL)).isEqualTo(0); + } + } +} diff --git a/src/test/java/baseball/util/InputParserTest.java b/src/test/java/baseball/util/InputParserTest.java new file mode 100644 index 0000000000..44dd56bb0e --- /dev/null +++ b/src/test/java/baseball/util/InputParserTest.java @@ -0,0 +1,69 @@ +package baseball.util; + +import baseball.exception.InputNotNumericException; +import baseball.exception.InputNullOrBlankException; +import baseball.exception.InputNumberOverflowException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EmptySource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class InputParserTest { + @Nested + class SuccessTest { + @DisplayName("양 옆의 공백을 제거한 문자열을 반환한다.") + @ParameterizedTest + @CsvSource(value = {" 가나디 ,가나디", " 먀오,먀오", "헤이 ,헤이"}) + void should_ReturnTrimmedString(String input, String expected) { + // when & then + assertThat(InputParser.refineInput(input)).isEqualTo(expected); + } + + @DisplayName("문자열을 숫자로 변환한다.") + @ParameterizedTest + @CsvSource(value = {" 1 ,1", " 2,2", "3 ,3", "4,4"}) + void should_ReturnConvertedIntValue(String input, int expected) { + // when & then + assertThat(InputParser.parseToInt(input)).isEqualTo(expected); + } + } + + @Nested + class ExceptionTest { + @DisplayName("Null, 빈 문자열, 공백 문자열을 입력받을 경우 예외가 발생한다.") + @ParameterizedTest + @NullSource + @EmptySource + @ValueSource(strings = " ") + void should_ThrowException_ForNullOfEmptyOrBlank(String input) { + assertThatThrownBy(() -> InputParser.refineInput(input)) + .isInstanceOf(InputNullOrBlankException.class); + } + + @DisplayName("숫자로 변환 불가한 문자열을 입력받을 경우 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"3천원", "3k", "$1000", "3,000", "3000.00", "3 000"}) + void should_ThrowException_When_NotConvertibleToNumeric(String input) { + // when & then + assertThatThrownBy(() -> InputParser.parseToInt(input)) + .isInstanceOf(InputNotNumericException.class); + } + + @DisplayName("Integer의 범위를 초과하는 문자열을 입력받을 경우 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"999999999999999999999999999999", Integer.MAX_VALUE + 1L + ""}) + void should_ThrowException_When_IntegerOverflow(String input) { + // when & then + assertThatThrownBy(() -> InputParser.parseToInt(input)) + .isInstanceOf(InputNumberOverflowException.class); + } + } + + +}