diff --git a/README.md b/README.md index 8d7e8aee..2ff45774 100644 --- a/README.md +++ b/README.md @@ -1 +1,60 @@ -# java-baseball-precourse \ No newline at end of file +# java-baseball-precourse + +## 기능 목록 + +숫자야구 게임 기능을 구현해야 한다. + +### 게임 시작 및 상대방(컴퓨터) 숫자 생성 + - 1에서 9까지의 서로 다른 임의의 수 3개를 생성하는 기능 + - 게임 시작 문구 출력 ("숫자 야구 게임을 시작합니다.") + - 컴퓨터의 랜덤 숫자 생성 로직 구현 + +### 사용자 입력 및 예외 처리 + - 사용자로부터 3자리 숫자를 입력받는 기능 + - 잘못된 값을 입력할 경우 [ERROR]로 시작하는 에러 메시지를 출력 + - 3자리 숫자가 아닌 경우 + - 숫자가 아닌 문자가 포함된 경우 + - 중복된 숫자가 있는 경우 + - 0이 포함된 경우 (문제 조건에 따라 1~9 사이인지 확인) + - 사용자 입력 기능 및 유효성 검사 로직 구현 + +### 게임 결과 판정 (힌트 생성) + - 입력한 숫자와 컴퓨터 숫자를 비교하여 결과 계산 + - 스트라이크: 같은 수가 같은 자리에 있는 경우 + - 볼: 같은 수가 다른 자리에 있는 경우 + - 낫싱: 같은 수가 전혀 없는 경우 + + - 판정 결과를 텍스트로 변환 (예: "1볼 1스트라이크", "낫싱") + - 스트라이크/볼 카운트 및 판정 결과 출력 로직 구현 + +### 게임 종료 및 재시작/완전 종료 + - 3개 숫자를 모두 맞혔을 경우 안내 문구 출력 ("3스트라이크 \n 3개의 숫자를 모두 맞히셨습니다! 게임 종료") + - 게임 종료 후 재시작(1) 또는 종료(2)를 선택받는 기능 + - 1 입력 시: 새로운 숫자를 생성하고 게임 다시 시작 + - 2 입력 시: 애플리케이션 완전 종료 + - 게임 종료 조건 확인 및 재시작/종료 선택 로직 구현 + +--- +## 프로그래밍 요구사항1 - 제약사항 + +- 자바 코드 컨벤션을 지키면서 프로그래밍한다. + - https://naver.github.io/hackday-conventions-java/ +- indent(인덴트, 들여쓰기) depth를 3이 넘지 않도록 구현한다. 2까지만 허용한다. + - 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다. + - 힌트: indent(인덴트, 들여쓰기) depth를 줄이는 좋은 방법은 함수(또는 메소드)를 분리하면 된다. +- 자바 8에 추가된 stream api를 사용하지 않고 구현해야 한다. 단, 람다는 사용 가능하다. +- else 예약어를 쓰지 않는다. + - 힌트: if 조건절에서 값을 return하는 방식으로 구현하면 else를 사용하지 않아도 된다. + - else를 쓰지 말라고 하니 switch/case로 구현하는 경우가 있는데 switch/case도 허용하지 않는다. +- 함수(또는 메소드)의 길이가 15라인을 넘어가지 않도록 구현한다. + - 함수(또는 메소드)가 한 가지 일만 잘 하도록 구현한다 + +--- +## 프로그래밍 요구사항2 - 단위 테스트 + +- 도메인 로직에 단위 테스트를 구현해야 한다. 단, UI(System.out, System.in, Scanner) 로직은 제외 + - 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 분리해 구현한다. + - 힌트는 MVC 패턴 기반으로 구현한 후 View, Controller를 제외한 Model에 대한 단위 테스트를 추가하는 것에 집 + 중한다. +- JUnit5와 AssertJ 사용법에 익숙하지 않은 개발자는 첨부한 "학습테스트를 통해 JUnit 학습하기.pdf" 문서를 참고해 + 사용법을 학습한 후 JUnit5 기반 단위 테스트를 구현한다. \ No newline at end of file diff --git a/src/main/java/BaseballGameClient.java b/src/main/java/BaseballGameClient.java new file mode 100644 index 00000000..f2d324a4 --- /dev/null +++ b/src/main/java/BaseballGameClient.java @@ -0,0 +1,10 @@ +import controller.BaseballGameController; + +public class BaseballGameClient { + + public static void main(String[] args) { + BaseballGameController gameController = new BaseballGameController(); + gameController.start(); + } +} + diff --git a/src/main/java/controller/BaseballGameController.java b/src/main/java/controller/BaseballGameController.java new file mode 100644 index 00000000..a948206e --- /dev/null +++ b/src/main/java/controller/BaseballGameController.java @@ -0,0 +1,59 @@ +package controller; + +import domain.BaseballNumbers; +import service.BaseballService; +import utils.RandomNumberGenerator; +import view.InputView; +import view.OutputView; + +public class BaseballGameController { + private final BaseballService baseballService = new BaseballService(); + private final InputView inputView = new InputView(); + private final OutputView outputView = new OutputView(); + + public void start() { + boolean isRunning = true; + while (isRunning) { + playOneGame(); + isRunning = askRestart(); + } + } + + private void playOneGame() { + outputView.printStartMessage(); + BaseballNumbers computer = new BaseballNumbers(RandomNumberGenerator.generate()); + boolean isGameEnd = false; + while (!isGameEnd) { + isGameEnd = playTurn(computer); + } + outputView.printGameEnd(); + } + + private boolean playTurn(BaseballNumbers computer) { + BaseballNumbers player = askPlayerNumbers(); + String result = baseballService.playRound(computer, player); + outputView.printResult(result); + return result.equals("3스트라이크"); + } + + private BaseballNumbers askPlayerNumbers() { + while (true) { + try { + return new BaseballNumbers(inputView.readInput()); + } catch (IllegalArgumentException e) { + outputView.printErrorMessage(e.getMessage()); + } + } + } + + private boolean askRestart() { + while (true) { + try { + String input = inputView.readRestartInput(); + return input.equals("1"); + } catch (IllegalArgumentException e) { + outputView.printErrorMessage(e.getMessage()); + } + } + } +} diff --git a/src/main/java/domain/BaseballNumbers.java b/src/main/java/domain/BaseballNumbers.java new file mode 100644 index 00000000..1e906449 --- /dev/null +++ b/src/main/java/domain/BaseballNumbers.java @@ -0,0 +1,95 @@ +package domain; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class BaseballNumbers { + private final List numbers; + + public BaseballNumbers(String numbers) { + validateLength(numbers); + this.numbers = parseInput(numbers); + } + + public BaseballNumbers(List numbers) { + this.numbers = numbers; + } + + private List parseInput(String input) { + List numbers = new ArrayList<>(); + + for (char c : input.toCharArray()) { + validateNumeric(c); + int number = Character.getNumericValue(c); + validateNumberRange(number); + + numbers.add(number); + } + + validateDuplicate(numbers); + return numbers; + } + + private void validateDuplicate(List numbers) { + HashSet compare = new HashSet<>(numbers); + + if (numbers.size() != compare.size()) { + throw new IllegalArgumentException("중복된 숫자가 있습니다."); + } + } + + private void validateLength(String numbers) { + if (numbers.length() != 3) throw new IllegalArgumentException("입력은 세 글자만 가능합니다."); + } + + private void validateNumeric(char c) { + if (!Character.isDigit(c)) { + throw new IllegalArgumentException("숫자 이외의 문자는 입력할 수 없습니다."); + } + } + + private void validateNumberRange(int number) { + if (number < 1 || number > 9) { + throw new IllegalArgumentException("1에서 9 사이의 숫자만 입력 가능합니다."); + } + } + + public int countStrike(BaseballNumbers other) { + int strike = 0; + for (int i = 0; i < numbers.size(); i++) { + strike += checkStrikeAt(other, i); + } + return strike; + } + + private int checkStrikeAt(BaseballNumbers other, int index) { + if (this.numbers.get(index).equals(other.numbers.get(index))) { + return 1; + } + return 0; + } + + public int countBall(BaseballNumbers other) { + int totalMatching = 0; + Set computerSet = new HashSet<>(this.numbers); + + for (int number : other.numbers) { + totalMatching += checkMatching(computerSet, number); + } + + return totalMatching - countStrike(other); + } + + private int checkMatching(Set set, int number) { + if (set.contains(number)) { + return 1; + } + return 0; + } + + public List getNumbers() { + return numbers; + } +} diff --git a/src/main/java/service/BaseballService.java b/src/main/java/service/BaseballService.java new file mode 100644 index 00000000..9f8a7845 --- /dev/null +++ b/src/main/java/service/BaseballService.java @@ -0,0 +1,31 @@ +package service; + +import domain.BaseballNumbers; + + +public class BaseballService { + + public String playRound(BaseballNumbers computer, BaseballNumbers player) { + int strike = computer.countStrike(player); + int ball = computer.countBall(player); + return getResultMessage(strike, ball); + } + + public String getResultMessage(int strike, int ball) { + if (strike == 0 && ball == 0) { + return "낫싱"; + } + return buildStrikeBallMessage(strike, ball).trim(); + } + + private String buildStrikeBallMessage(int strike, int ball) { + StringBuilder message = new StringBuilder(); + if (ball > 0) { + message.append(ball).append("볼 "); + } + if (strike > 0) { + message.append(strike).append("스트라이크"); + } + return message.toString(); + } +} diff --git a/src/main/java/utils/RandomNumberGenerator.java b/src/main/java/utils/RandomNumberGenerator.java new file mode 100644 index 00000000..30116aac --- /dev/null +++ b/src/main/java/utils/RandomNumberGenerator.java @@ -0,0 +1,39 @@ +package utils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +public class RandomNumberGenerator { + private static final Random random = new Random(); + + public static List generate() { + List numbers = new ArrayList<>(); + int index = 0; + + while (index < 3) { + int num = getNumber(); + if (isDuplicate(numbers, num)){ + continue; + } + + numbers.add(num); + index++; + } + + return numbers; + } + + private static int getNumber(){ + return random.nextInt(1, 10); + } + + private static boolean isDuplicate(List numbers, int num){ + for (Integer number : numbers) { + if (number == num) + return true; + } + + return false; + } +} diff --git a/src/main/java/view/InputView.java b/src/main/java/view/InputView.java new file mode 100644 index 00000000..8b131061 --- /dev/null +++ b/src/main/java/view/InputView.java @@ -0,0 +1,23 @@ +package view; + +import java.util.Scanner; + +public class InputView { + Scanner sc = new Scanner(System.in); + + public String readInput() { + return sc.nextLine(); + } + + public String readRestartInput() { + String input = readInput(); // 공통 입력 메서드 호출 + validateRestart(input); + return input; + } + + void validateRestart(String input) { + if (!input.equals("1") && !input.equals("2")) { + throw new IllegalArgumentException("1 또는 2만 입력 가능합니다."); + } + } +} diff --git a/src/main/java/view/OutputView.java b/src/main/java/view/OutputView.java new file mode 100644 index 00000000..cb7a1bff --- /dev/null +++ b/src/main/java/view/OutputView.java @@ -0,0 +1,25 @@ +package view; + +public class OutputView { + private static final String ERROR_PREFIX = "[ERROR] "; + private static final String GAME_START_MESSAGE = "숫자 야구 게임을 시작합니다."; + private static final String GAME_END_MESSAGE = "3개의 숫자를 모두 맞히셨습니다! 게임 종료"; + private static final String RESTART_GUIDE_MESSAGE = "게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요."; + + public void printStartMessage() { + System.out.println(GAME_START_MESSAGE); + } + + public void printResult(String resultMessage) { + System.out.println(resultMessage); + } + + public void printGameEnd() { + System.out.println(GAME_END_MESSAGE); + System.out.println(RESTART_GUIDE_MESSAGE); + } + + public void printErrorMessage(String message) { + System.out.println(ERROR_PREFIX + message); + } +} diff --git a/src/test/java/domain/BaseballNumbersTest.java b/src/test/java/domain/BaseballNumbersTest.java new file mode 100644 index 00000000..3cd2bd06 --- /dev/null +++ b/src/test/java/domain/BaseballNumbersTest.java @@ -0,0 +1,87 @@ +package domain; + +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; +import org.junit.jupiter.params.provider.ValueSource; + +public class BaseballNumbersTest { + + @ParameterizedTest + @ValueSource(strings = {"12", "1234", ""}) + @DisplayName("입력된 숫자가 3자리가 아니면 IllegalArgumentException이 발생한다") + void validateLengthTest(String input) { + assertThatThrownBy(() -> new BaseballNumbers(input)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("정상적인 숫자 입력 시 List로 변환되어 저장된다") + void createNumbersTest() { + BaseballNumbers numbers = new BaseballNumbers("123"); + assertThat(numbers.getNumbers()).containsExactly(1, 2, 3); + } + + @ParameterizedTest + @ValueSource(strings = {"112", "122", "333", "919"}) + @DisplayName("중복된 숫자가 입력되면 IllegalArgumentException이 발생한다") + void validateDuplicateTest(String input) { + assertThatThrownBy(() -> new BaseballNumbers(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("중복된 숫자가 있습니다."); + } + + @ParameterizedTest + @ValueSource(strings = {"aaa", "AAA", "춘식이", "라2언"}) + @DisplayName("문자 입력시 IllegalArgumentException이 발생한다") + void validateNumericTest(String input) { + assertThatThrownBy(() -> new BaseballNumbers(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("숫자 이외의 문자는 입력할 수 없습니다."); + } + + @Test + @DisplayName("1미만, 9초과시 IllegalArgumentException이 발생한다") + void validateNumberRangeTest() { + assertThatThrownBy(() -> new BaseballNumbers("000")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1에서 9 사이의 숫자만 입력 가능합니다."); + } + + @ParameterizedTest + @CsvSource(value = { + "123:3", // 3스트라이크 + "124:2", // 2스트라이크 + "145:1", // 1스트라이크 + "456:0" // 0스트라이크 + }, delimiter = ':') + @DisplayName("다양한 입력값에 대해 스트라이크 개수를 검증한다") + void countStrikeTest(String input, int expectedStrike) { + BaseballNumbers computer = new BaseballNumbers("123"); + BaseballNumbers player = new BaseballNumbers(input); + + int actualStrike = computer.countStrike(player); + + assertThat(actualStrike).isEqualTo(expectedStrike); + } + + @ParameterizedTest + @CsvSource(value = { + "312:3", // 3볼 + "132:2", // 2볼 + "761:1", // 1볼 + "456:0" // 낫싱 + }, delimiter = ':') + @DisplayName("다양한 입력값에 대해 볼 개수를 검증한다") + void countBallTest(String input, int expectedBall) { + BaseballNumbers computer = new BaseballNumbers("123"); + BaseballNumbers player = new BaseballNumbers(input); + + int actualStrike = computer.countBall(player); + + assertThat(actualStrike).isEqualTo(expectedBall); + } +} diff --git a/src/test/java/service/BaseballServiceTest.java b/src/test/java/service/BaseballServiceTest.java new file mode 100644 index 00000000..ea9df70c --- /dev/null +++ b/src/test/java/service/BaseballServiceTest.java @@ -0,0 +1,28 @@ +package service; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +public class BaseballServiceTest { + + private final BaseballService baseballService = new BaseballService(); + + @ParameterizedTest + @CsvSource(value = { + "0, 0, 낫싱", + "0, 1, 1볼", + "0, 3, 3볼", + "1, 0, 1스트라이크", + "3, 0, 3스트라이크", + "1, 1, 1볼 1스트라이크", + "2, 1, 1볼 2스트라이크" // 상황에 따라 순서 확인 필요 (보통 볼이 먼저) + }) + @DisplayName("스트라이크와 볼 개수에 따라 정확한 한글 메시지를 생성한다") + void getResultMessageTest(int strike, int ball, String expected) { + String result = baseballService.getResultMessage(strike, ball); + assertThat(result).isEqualTo(expected); + } +} diff --git a/src/test/java/view/InputViewTest.java b/src/test/java/view/InputViewTest.java new file mode 100644 index 00000000..e71bd1e1 --- /dev/null +++ b/src/test/java/view/InputViewTest.java @@ -0,0 +1,18 @@ +package view; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class InputViewTest { + + private final InputView inputView = new InputView(); + + @ParameterizedTest + @ValueSource(strings = {"3", "11", "22", " "}) + void validateRestartTest(String value){ + Assertions.assertThatThrownBy(() -> inputView.validateRestart(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("1 또는 2만 입력 가능합니다."); + } +}