diff --git a/README.md b/README.md index d0286c8..73d2745 100644 --- a/README.md +++ b/README.md @@ -1 +1,107 @@ -# java-racingcar-precourse +# java-racingCar-precourse + +## 기능 요구 사항 +초간단 자동차 경주 게임을 구현한다. + +- 주어진 횟수 동안 n대의 자동차는 전진 또는 멈출 수 있다. +- 각 자동차에 이름을 부여할 수 있다. 전진하는 자동차를 출력할 때 자동차 이름을 같이 출력한다. +- 자동차 이름은 쉼표(,)를 기준으로 구분하며 이름은 5자 이하만 가능하다. +- 사용자는 몇 번의 이동을 할 것인지를 입력할 수 있어야 한다. +- 전진하는 조건은 0에서 9 사이에서 무작위 값을 구한 후 무작위 값이 4 이상일 경우이다. +- 자동차 경주 게임을 완료한 후 누가 우승했는지를 알려준다. 우승자는 한 명 이상일 수 있다. +- 우승자가 여러 명일 경우 쉼표(,)를 이용하여 구분한다. +- 사용자가 잘못된 값을 입력할 경우 `IllegalArgumentException`을 발생시킨 후 애플리케이션은 종료되어야 한다. + +## 입출력 요구 사항 +**입력** +- 경주 할 자동차 이름(이름은 쉼표(,) 기준으로 구분) +``` +pobi,woni,jun +``` + +- 시도할 회수 +``` +5 +``` + +## 출력 +**각 차수별 실행 결과** +``` +pobi : -- +woni : ---- +jun : --- +``` + +**단독 우승자 안내 문구** +``` +최종 우승자 : pobi +``` + +**공동 우승자 안내 문구** +``` +최종 우승자 : pobi, jun +``` + +**실행 결과 예시** +``` +경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분) +pobi,woni,jun +시도할 회수는 몇회인가요? +5 + +실행 결과 +pobi : - +woni : +jun : - + +pobi : -- +woni : - +jun : -- + +pobi : --- +woni : -- +jun : --- + +pobi : ---- +woni : --- +jun : ---- + +pobi : ----- +woni : ---- +jun : ----- + +최종 우승자 : pobi, jun +``` + +## Java - 프로그래밍 요구 사항 + +- JDK 17 버전에서 실행 가능해야 한다. **JDK 17에서 정상적으로 동작하지 않을 경우 0점 처리한다.** +- 프로그램 실행의 시작점은 `Application`의 `main()`이다. +- `build.gradle` 파일을 변경할 수 없고, 외부 라이브러리를 사용하지 않는다. +- [Java 코드 컨벤션](https://github.com/woowacourse/woowacourse-docs/tree/master/styleguide/java) 가이드를 준수하며 프로그래밍한다. +- 프로그램 종료 시 `System.exit()`를 호출하지 않는다. +- 프로그램 구현이 완료되면 `ApplicationTest`의 모든 테스트가 성공해야 한다. **테스트가 실패할 경우 0점 처리한다.** +- 프로그래밍 요구 사항에서 달리 명시하지 않는 한 파일, 패키지 이름을 수정하거나 이동하지 않는다. + +### 추가된 요구 사항 + +- indent(인덴트, 들여쓰기) depth를 3이 넘지 않도록 구현한다. 2까지만 허용한다. + - 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다. + - 힌트: indent(인덴트, 들여쓰기) depth를 줄이는 좋은 방법은 함수(또는 메서드)를 분리하면 된다. +- 3항 연산자를 쓰지 않는다. +- 함수(또는 메서드)가 한 가지 일만 하도록 최대한 작게 만들어라. +- JUnit 5와 AssertJ를 이용하여 본인이 정리한 기능 목록이 정상 동작함을 테스트 코드로 확인한다. + - 테스트 도구 사용법이 익숙하지 않다면 `test/java/study`를 참고하여 학습한 후 테스트를 구현한다. + +### 라이브러리 + +- JDK에서 제공하는 Random 및 Scanner API 대신 `camp.nextstep.edu.missionutils`에서 제공하는 `Randoms` 및 `Console` API를 사용하여 구현해야 한다. + - Random 값 추출은 `camp.nextstep.edu.missionutils.Randoms`의 `pickNumberInRange()`를 활용한다. + - 사용자가 입력하는 값은 `camp.nextstep.edu.missionutils.Console`의 `readLine()`을 활용한다. + +### 사용 예시 + +- 0에서 9까지의 정수 중 한 개의 정수 반환 +``` +Randoms.pickNumberInRange(0,9); +``` \ No newline at end of file diff --git a/src/main/java/racingcar/Application.java b/src/main/java/racingcar/Application.java index a17a52e..87b312c 100644 --- a/src/main/java/racingcar/Application.java +++ b/src/main/java/racingcar/Application.java @@ -1,7 +1,12 @@ package racingcar; +import racingcar.config.AppConfig; +import racingcar.controller.RacingGameController; + public class Application { public static void main(String[] args) { - // TODO: 프로그램 구현 + AppConfig appConfig = new AppConfig(); + RacingGameController controller = appConfig.getRacingGameController(); + controller.run(); } -} +} \ No newline at end of file diff --git a/src/main/java/racingcar/config/AppConfig.java b/src/main/java/racingcar/config/AppConfig.java new file mode 100644 index 0000000..59fbc35 --- /dev/null +++ b/src/main/java/racingcar/config/AppConfig.java @@ -0,0 +1,23 @@ +package racingcar.config; + +import racingcar.controller.RacingGameController; +import racingcar.service.RacingService; +import racingcar.util.RandomNumberGenerator; +import racingcar.view.InputView; +import racingcar.view.OutputView; + +public class AppConfig { + private final RacingGameController racingGameController; + + public AppConfig() { + RandomNumberGenerator randomNumberGenerator = new RandomNumberGenerator(); + InputView inputView = new InputView(); + OutputView outputView = new OutputView(); + RacingService racingService = new RacingService(randomNumberGenerator, outputView); + this.racingGameController = new RacingGameController(inputView, racingService); + } + + public RacingGameController getRacingGameController() { + return racingGameController; + } +} \ No newline at end of file diff --git a/src/main/java/racingcar/controller/RacingGameController.java b/src/main/java/racingcar/controller/RacingGameController.java new file mode 100644 index 0000000..f3e93b2 --- /dev/null +++ b/src/main/java/racingcar/controller/RacingGameController.java @@ -0,0 +1,23 @@ +package racingcar.controller; + +import racingcar.service.RacingService; +import racingcar.view.InputView; + +import java.util.List; + +public class RacingGameController { + private final InputView inputView; + private final RacingService racingService; + + public RacingGameController(InputView inputView, RacingService racingService) { + this.inputView = inputView; + this.racingService = racingService; + } + + public void run() { + List carNames = inputView.getCarNames(); + int attemptCount = inputView.getAttemptCount(); + + racingService.startRace(carNames, attemptCount); + } +} \ No newline at end of file diff --git a/src/main/java/racingcar/domain/Car.java b/src/main/java/racingcar/domain/Car.java new file mode 100644 index 0000000..8a329f1 --- /dev/null +++ b/src/main/java/racingcar/domain/Car.java @@ -0,0 +1,33 @@ +package racingcar.domain; + +import racingcar.util.RandomNumberGenerator; + +public class Car { + private final String name; + private int position; + private final RandomNumberGenerator randomNumberGenerator; + + public Car(String name, RandomNumberGenerator randomNumberGenerator) { + this.name = name; + this.position = 0; + this.randomNumberGenerator = randomNumberGenerator; + } + + public void move() { + move(randomNumberGenerator.canMove()); // 기본적으로 랜덤한 이동 + } + + public void move(boolean canMove) { + if (canMove) { + position++; + } + } + + public String getName() { + return name; + } + + public int getPosition() { + return position; + } +} \ No newline at end of file diff --git a/src/main/java/racingcar/domain/Race.java b/src/main/java/racingcar/domain/Race.java new file mode 100644 index 0000000..b1e697d --- /dev/null +++ b/src/main/java/racingcar/domain/Race.java @@ -0,0 +1,36 @@ +package racingcar.domain; + +import racingcar.util.RandomNumberGenerator; + +import java.util.List; +import java.util.stream.Collectors; + +public class Race { + private final List cars; + + public Race(List carNames, RandomNumberGenerator randomNumberGenerator) { + this.cars = carNames.stream() + .map(name -> new Car(name, randomNumberGenerator)) + .collect(Collectors.toList()); + } + + public void moveCars() { + cars.forEach(Car::move); + } + + public List getCars() { + return cars; + } + + public List getWinners() { + int maxPosition = cars.stream() + .mapToInt(Car::getPosition) + .max() + .orElse(0); + + return cars.stream() + .filter(car -> car.getPosition() == maxPosition) + .map(Car::getName) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/racingcar/service/RacingService.java b/src/main/java/racingcar/service/RacingService.java new file mode 100644 index 0000000..39b9397 --- /dev/null +++ b/src/main/java/racingcar/service/RacingService.java @@ -0,0 +1,33 @@ +package racingcar.service; + +import racingcar.domain.Race; +import racingcar.util.RandomNumberGenerator; +import racingcar.view.OutputView; + +import java.util.List; +import java.util.stream.IntStream; + +public class RacingService { + private final RandomNumberGenerator randomNumberGenerator; + private final OutputView outputView; + + public RacingService(RandomNumberGenerator randomNumberGenerator, OutputView outputView) { + this.randomNumberGenerator = randomNumberGenerator; + this.outputView = outputView; + } + + public void startRace(List carNames, int attemptCount) { + Race race = new Race(carNames, randomNumberGenerator); + outputView.printStartMessage(); + runRace(race, attemptCount); + outputView.printWinners(race.getWinners()); + } + + private void runRace(Race race, int attemptCount) { + IntStream.range(0, attemptCount) + .forEach(i -> { + race.moveCars(); + outputView.printRaceStatus(race.getCars()); + }); + } +} \ No newline at end of file diff --git a/src/main/java/racingcar/util/RandomNumberGenerator.java b/src/main/java/racingcar/util/RandomNumberGenerator.java new file mode 100644 index 0000000..c4080de --- /dev/null +++ b/src/main/java/racingcar/util/RandomNumberGenerator.java @@ -0,0 +1,14 @@ +package racingcar.util; + +import camp.nextstep.edu.missionutils.Randoms; + +public class RandomNumberGenerator { + private static final int MIN = 0; + private static final int MAX = 9; + private static final int MOVE_THRESHOLD = 4; + + public boolean canMove() { + int randomValue = Randoms.pickNumberInRange(MIN, MAX); + return randomValue >= MOVE_THRESHOLD; + } +} \ No newline at end of file diff --git a/src/main/java/racingcar/validator/InputValidator.java b/src/main/java/racingcar/validator/InputValidator.java new file mode 100644 index 0000000..0e7adc4 --- /dev/null +++ b/src/main/java/racingcar/validator/InputValidator.java @@ -0,0 +1,42 @@ +package racingcar.validator; + +import java.util.List; + +public class InputValidator { + public static void validateCarNames(List carNames) { + if (carNames.isEmpty()) { + throw new IllegalArgumentException("[ERROR] 자동차 이름을 입력해야 합니다."); + } + if (hasDuplicateNames(carNames)) { + throw new IllegalArgumentException("[ERROR] 자동차 이름은 중복될 수 없습니다."); + } + if (hasInvalidLength(carNames)) { + throw new IllegalArgumentException("[ERROR] 자동차 이름은 5자 이하만 가능합니다."); + } + if (carNames.stream().anyMatch(name -> !name.matches("^[a-zA-Z0-9]*$"))) { + throw new IllegalArgumentException("[ERROR] 자동차 이름은 영어 또는 숫자만 가능합니다."); + } + } + + public static void validateAttemptCount(String input) { + if (!isNumeric(input)) { + throw new IllegalArgumentException("[ERROR] 유효한 숫자를 입력해야 합니다."); + } + int count = Integer.parseInt(input); + if (count <= 0) { + throw new IllegalArgumentException("[ERROR] 시도 횟수는 1 이상이어야 합니다."); + } + } + + private static boolean hasDuplicateNames(List carNames) { + return carNames.size() != carNames.stream().distinct().count(); + } + + private static boolean hasInvalidLength(List carNames) { + return carNames.stream().anyMatch(name -> name.length() > 5); + } + + private static boolean isNumeric(String input) { + return input.matches("\\d+"); + } +} \ No newline at end of file diff --git a/src/main/java/racingcar/view/InputView.java b/src/main/java/racingcar/view/InputView.java new file mode 100644 index 0000000..58e30f7 --- /dev/null +++ b/src/main/java/racingcar/view/InputView.java @@ -0,0 +1,28 @@ +package racingcar.view; + +import camp.nextstep.edu.missionutils.Console; +import racingcar.validator.InputValidator; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +public class InputView { + public List getCarNames() { + System.out.println("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"); + String input = Console.readLine(); + List carNames = Arrays.stream(input.split(",")) + .map(String::trim) + .filter(name -> !name.isEmpty()) // 빈 문자열 제거 + .collect(Collectors.toList()); + InputValidator.validateCarNames(carNames); + return carNames; + } + + public int getAttemptCount() { + System.out.println("시도할 회수는 몇회인가요?"); + String input = Console.readLine(); + InputValidator.validateAttemptCount(input); + return Integer.parseInt(input); + } +} \ No newline at end of file diff --git a/src/main/java/racingcar/view/OutputView.java b/src/main/java/racingcar/view/OutputView.java new file mode 100644 index 0000000..fc54a04 --- /dev/null +++ b/src/main/java/racingcar/view/OutputView.java @@ -0,0 +1,24 @@ +package racingcar.view; + +import java.util.stream.Collectors; +import racingcar.domain.Car; + +import java.util.List; + +public class OutputView { + public void printStartMessage() { + System.out.println("\n실행 결과"); + } + + public void printRaceStatus(List cars) { + String raceStatus = cars.stream() + .map(car -> car.getName() + " : " + "-".repeat(Math.max(0, car.getPosition()))) + .collect(Collectors.joining("\n")); + + System.out.println(raceStatus + "\n"); + } + + public void printWinners(List winners) { + System.out.println("최종 우승자 : " + String.join(", ", winners)); + } +} \ No newline at end of file diff --git a/src/test/java/racingcar/ApplicationTest.java b/src/test/java/racingcar/ApplicationTest.java index 1d35fc3..06d1aed 100644 --- a/src/test/java/racingcar/ApplicationTest.java +++ b/src/test/java/racingcar/ApplicationTest.java @@ -2,6 +2,7 @@ import camp.nextstep.edu.missionutils.test.NsTest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; import static camp.nextstep.edu.missionutils.test.Assertions.assertRandomNumberInRangeTest; import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest; @@ -31,6 +32,16 @@ class ApplicationTest extends NsTest { ); } + @Test + @DisplayName("시도 횟수가 숫자가 아닐 경우 예외 발생") + void 시도_횟수_숫자_예외() { + assertSimpleTest(() -> + assertThatThrownBy(() -> runException("pobi,woni", "abc")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR] 유효한 숫자를 입력해야 합니다.") + ); + } + @Override public void runMain() { Application.main(new String[]{}); diff --git a/src/test/java/racingcar/InputValidatorTest.java b/src/test/java/racingcar/InputValidatorTest.java new file mode 100644 index 0000000..cc7e6a9 --- /dev/null +++ b/src/test/java/racingcar/InputValidatorTest.java @@ -0,0 +1,71 @@ +package racingcar; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import racingcar.validator.InputValidator; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InputValidatorTest { + + @Test + @DisplayName("자동차 이름이 비어있을 경우 예외 발생") + void validateCarNames_emptyList_throwsException() { + List emptyList = List.of(); + assertThatThrownBy(() -> InputValidator.validateCarNames(emptyList)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("[ERROR] 자동차 이름을 입력해야 합니다."); + } + + @Test + @DisplayName("자동차 이름이 중복될 경우 예외 발생") + void validateCarNames_duplicateNames_throwsException() { + List carNames = Arrays.asList("pobi", "woni", "pobi"); + assertThatThrownBy(() -> InputValidator.validateCarNames(carNames)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("[ERROR] 자동차 이름은 중복될 수 없습니다."); + } + + @Test + @DisplayName("자동차 이름이 5자를 초과할 경우 예외 발생") + void validateCarNames_tooLongNames_throwsException() { + List carNames = Arrays.asList("pobiii", "woni"); + assertThatThrownBy(() -> InputValidator.validateCarNames(carNames)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("[ERROR] 자동차 이름은 5자 이하만 가능합니다."); + } + + @Test + @DisplayName("자동차 이름에 특수 문자가 포함될 경우 예외 발생") + void validateCarNames_invalidCharacters_throwsException() { + List carNames = Arrays.asList("pobi@", "woni123"); + assertThatThrownBy(() -> InputValidator.validateCarNames(carNames)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("[ERROR] 자동차 이름은 영어 또는 숫자만 가능합니다."); + } + + @Test + @DisplayName("시도 횟수가 숫자가 아닐 경우 예외 발생") + void validateAttemptCount_nonNumeric_throwsException() { + assertThatThrownBy(() -> InputValidator.validateAttemptCount("abc")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("[ERROR] 유효한 숫자를 입력해야 합니다."); + } + + @Test + @DisplayName("시도 횟수가 0 이하일 경우 예외 발생") + void validateAttemptCount_zeroOrNegative_throwsException() { + assertThatThrownBy(() -> InputValidator.validateAttemptCount("0")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("[ERROR] 시도 횟수는 1 이상이어야 합니다."); + } + + @Test + @DisplayName("시도 횟수가 정상적인 경우 예외가 발생하지 않음") + void validateAttemptCount_validInput_noException() { + InputValidator.validateAttemptCount("5"); + } +} \ No newline at end of file