diff --git a/README.md b/README.md index d0286c859f..5809ec3fa8 100644 --- a/README.md +++ b/README.md @@ -1 +1,59 @@ # java-racingcar-precourse + + +## 구현할 기능 목록 + +### controller +- [x] 전체 게임의 실행 흐름을 관리한다. + - [x] 사용자 입력을 통해 자동차 이름과 시도 횟수를 전달받는다. + - [x] 입력받은 데이터를 기반으로 경주를 시작한다. + - [x] 경주 결과와 우승자를 출력한다. + +### domain +- [x] 자동차를 생성한다.(**Cars**) + - [x] 자동차 이름을 쉼표(,)를 기준으로 구분한다. + - [x] 자동차 이름이 5자 이하인지 확인한다. + - [x] 자동차 이름이 비어 있거나 공백이 포함되어 있지 않은지 확인한다. + +- [x] 경주를 위한 유효성을 검증한다. + - [x] 경주에 참여하는 자동차가 둘 이상인지 확인한다. + - [x] 중복된 자동차 이름이 존재하지 않는지 검사한다. + + +- [x] 0~9 사이의 랜덤 값을 생성한다.(**RandomNumberGenerator**) + + +- [x] 자동차의 이동을 수행한다.(**Car**) + - [x] 랜덤 값이 4 이상일 경우 자동차를 전진시킨다. + + +- [x] 시도 횟수의 유효성을 검증한다.(**Rounds**) + - [x] 시도할 횟수는 숫자여야 한다. + - [x] 시도할 횟수는 양수여야 한다. + + +- [x] 경주 결과를 저장하고 우승자를 판별한다. + - [x] 가장 많이 전진한 자동차를 우승자로 결정한다. + - [x] 여러 명의 우승자가 있을 경우 모두 표시한다. + +### service +- [x] 각 라운드마다 자동차들의 이동을 반복 수행한다. +- [x] 시도 횟수에 따라 경주를 자동으로 종료한다. +- [x] 매 라운드의 진행 결과를 누적 저장한다. +- [x] 우승자를 계산한다. + +### constant +- [x] Input 관련 안내 메시지 관리한다. +- [x] Output 관련 출력 메시지 관리한다. +- [x] Error 관련 예외 메시지 관리한다. + +### view +- [x] 자동차 이름을 입력받는다. + + +- [x] 시도할 횟수를 입력받는다. + + +- [x] 경주 진행 결과를 출력한다. + - [x] 각 라운드별 자동차의 이동 결과를 출력한다. + - [x] 최종 우승자를 출력한다. diff --git a/src/main/java/racingcar/Application.java b/src/main/java/racingcar/Application.java index a17a52e724..5a44dc49f2 100644 --- a/src/main/java/racingcar/Application.java +++ b/src/main/java/racingcar/Application.java @@ -1,7 +1,11 @@ package racingcar; +import racingcar.controller.RacingController; + public class Application { public static void main(String[] args) { - // TODO: 프로그램 구현 + + new RacingController().run(); + } } diff --git a/src/main/java/racingcar/constant/ErrorMessage.java b/src/main/java/racingcar/constant/ErrorMessage.java new file mode 100644 index 0000000000..dbdd082ab0 --- /dev/null +++ b/src/main/java/racingcar/constant/ErrorMessage.java @@ -0,0 +1,15 @@ +package racingcar.constant; + +public class ErrorMessage { + + public static final String ERROR_CAR_NAME_EMPTY = "자동차 이름은 비어 있을 수 없습니다."; + public static final String ERROR_CAR_NAME_TOO_LONG = "자동차 이름은 5자 이하만 가능합니다."; + public static final String ERROR_CAR_NAME_BLANK = "자동차 이름은 빈 값을 입력할 수 없습니다."; + + public static final String ERROR_CAR_COUNT_TOO_SMALL = "경주에 참여할 자동차는 최소 2대 이상이어야 합니다."; + public static final String ERROR_CAR_NAME_DUPLICATE = "자동차 이름은 중복될 수 없습니다."; + + public static final String ERROR_ROUND_NOT_POSITIVE = "시도할 횟수를 숫자로 입력해주세요."; + public static final String ERROR_ROUND_NOT_NUMBER = "시도할 횟수를 양수로 입력해주세요."; + +} diff --git a/src/main/java/racingcar/constant/InputMessage.java b/src/main/java/racingcar/constant/InputMessage.java new file mode 100644 index 0000000000..ae2eacc4b3 --- /dev/null +++ b/src/main/java/racingcar/constant/InputMessage.java @@ -0,0 +1,8 @@ +package racingcar.constant; + +public class InputMessage { + + public static final String CAR_NAMES_INPUT_MESSAGE = "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"; + public static final String TRY_COUNT_INPUT_MESSAGE = "시도할 회수는 몇회인가요?"; + +} diff --git a/src/main/java/racingcar/constant/OutputMessage.java b/src/main/java/racingcar/constant/OutputMessage.java new file mode 100644 index 0000000000..32ddf28555 --- /dev/null +++ b/src/main/java/racingcar/constant/OutputMessage.java @@ -0,0 +1,8 @@ +package racingcar.constant; + +public class OutputMessage { + + public static final String RESULT_TITLE = "\n실행 결과"; + public static final String WINNER_MESSAGE = "최종 우승자 : "; + +} diff --git a/src/main/java/racingcar/controller/RacingController.java b/src/main/java/racingcar/controller/RacingController.java new file mode 100644 index 0000000000..093dd3e913 --- /dev/null +++ b/src/main/java/racingcar/controller/RacingController.java @@ -0,0 +1,53 @@ +package racingcar.controller; + +import racingcar.domain.*; +import racingcar.service.RaceService; +import racingcar.util.RandomNumberGenerator; +import racingcar.view.Input; +import racingcar.view.Output; + +import java.util.List; + +public class RacingController { + private final Input input; + private final Output output; + private final NumberGenerator numberGenerator; + + public RacingController() { + this.input = new Input(); + this.output = new Output(); + this.numberGenerator = new RandomNumberGenerator(); + } + + public void run() { + Cars cars = setupCars(); + Rounds rounds = setupRounds(); + + Race race = new Race(cars, rounds); + RaceService raceService = new RaceService(race); + + output.printResultTitle(); + int roundCount = race.getRoundCount(); + + for (int i = 0; i < roundCount; i++) { + raceService.runSingleRound(); + output.printCarsStatus(race.getCurrentCars()); + } + + List winners = raceService.getWinners(); + output.printWinners(winners); + } + + private Cars setupCars() { + String rawNames = input.inputCarNames(); + + return new Cars(rawNames, numberGenerator); + } + + private Rounds setupRounds() { + String rawCount = input.inputTryCount(); + + return new Rounds(rawCount); + } + +} diff --git a/src/main/java/racingcar/domain/Car.java b/src/main/java/racingcar/domain/Car.java new file mode 100644 index 0000000000..66a2eecf28 --- /dev/null +++ b/src/main/java/racingcar/domain/Car.java @@ -0,0 +1,62 @@ +package racingcar.domain; + +import static racingcar.constant.ErrorMessage.*; + +public class Car { + private static final int MAX_NAME_LENGTH = 5; + private static final int MOVE_CONDITION_NUMBER = 4; + + private final String name; + private int position = 0; + + public Car(String name) { + validate(name); + this.name = name; + } + + private void validate(String name) { + validateNameLength(name); + validateNoBlank(name); + validateNoEmpty(name); + } + + private void validateNameLength(String name) { + if (name.length() >= MAX_NAME_LENGTH) { + throw new IllegalArgumentException(ERROR_CAR_NAME_TOO_LONG); + } + } + + private void validateNoBlank(String name) { + if (name.isBlank()) { + throw new IllegalArgumentException(ERROR_CAR_NAME_BLANK); + } + } + + private void validateNoEmpty(String name) { + if (name.isEmpty()) { + throw new IllegalArgumentException(ERROR_CAR_NAME_EMPTY); + } + } + + public void move(int randomNumber) { + if(randomNumber > MOVE_CONDITION_NUMBER) { + position++; + } + } + + public String toResultString() { + return name + " : " + "-".repeat(position); + } + + public boolean isWinner(int maxPosition) { + return position == maxPosition; + } + + public String getName() { + return name; + } + + public int getPosition() { + return position; + } +} diff --git a/src/main/java/racingcar/domain/Cars.java b/src/main/java/racingcar/domain/Cars.java new file mode 100644 index 0000000000..21eba54b7e --- /dev/null +++ b/src/main/java/racingcar/domain/Cars.java @@ -0,0 +1,73 @@ +package racingcar.domain; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static racingcar.constant.ErrorMessage.*; + +public class Cars { + private static final int MINIMUMSIZE_CARS_COUNT = 2; + + private List cars; + + private final NumberGenerator numberGenerator; + + public Cars(String rawNames, NumberGenerator numberGenerator) { + List carNames = parseNames(rawNames); + validateList(carNames); + this.cars = carNames.stream() + .map(name -> new Car(name)) + .toList(); + this.numberGenerator = numberGenerator; + } + + private List parseNames(String rawNames) { + return Arrays.stream(rawNames.split(",")).toList(); + } + + private void validateList(List names) { + validateMinimumSize(names); + validateDuplicates(names); + } + + private void validateMinimumSize(List names) { + if (names.size() < MINIMUMSIZE_CARS_COUNT) { + throw new IllegalArgumentException(ERROR_CAR_COUNT_TOO_SMALL); + } + } + + private void validateDuplicates(List names) { + Set uniqueNames = new HashSet<>(names); + if (uniqueNames.size() != names.size()) { + throw new IllegalArgumentException(ERROR_CAR_NAME_DUPLICATE); + } + } + + public void moveAllCars() { + for (Car car : cars) { + int randomNumber = numberGenerator.pickNumber(); + + car.move(randomNumber); + } + } + + public List getCars() { + return cars; + } + + public List findWinners() { + int maxPosition = findMaxPosition(); + return cars.stream() + .filter(car -> car.getPosition() == maxPosition) + .toList(); + } + + private int findMaxPosition() { + return cars.stream() + .mapToInt(Car::getPosition) + .max() + .orElse(0); + } +} diff --git a/src/main/java/racingcar/domain/NumberGenerator.java b/src/main/java/racingcar/domain/NumberGenerator.java new file mode 100644 index 0000000000..04334d4fdf --- /dev/null +++ b/src/main/java/racingcar/domain/NumberGenerator.java @@ -0,0 +1,6 @@ +package racingcar.domain; + +public interface NumberGenerator { + + int pickNumber(); +} diff --git a/src/main/java/racingcar/domain/Race.java b/src/main/java/racingcar/domain/Race.java new file mode 100644 index 0000000000..db1e2bfca4 --- /dev/null +++ b/src/main/java/racingcar/domain/Race.java @@ -0,0 +1,29 @@ +package racingcar.domain; + +import java.util.List; + +public class Race { + private final Cars cars; + private final Rounds rounds; + + public Race(Cars cars, Rounds rounds) { + this.cars = cars; + this.rounds = rounds; + } + + public void runSingleRound() { + cars.moveAllCars(); + } + + public List findWinners() { + return cars.findWinners(); + } + + public List getCurrentCars() { + return cars.getCars(); + } + + public int getRoundCount() { + return rounds.getCount(); + } +} diff --git a/src/main/java/racingcar/domain/Rounds.java b/src/main/java/racingcar/domain/Rounds.java new file mode 100644 index 0000000000..563d079577 --- /dev/null +++ b/src/main/java/racingcar/domain/Rounds.java @@ -0,0 +1,31 @@ +package racingcar.domain; + +import static racingcar.constant.ErrorMessage.*; + +public class Rounds { + + private final int count; + + public Rounds(String rawCount) { + this.count = parseToInt(rawCount); + validatePositive(count); + } + + private int parseToInt(String rawCount) { + try { + return Integer.parseInt(rawCount); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(ERROR_ROUND_NOT_POSITIVE); + } + } + + private void validatePositive(int count) { + if (count <= 0) { + throw new IllegalArgumentException(ERROR_ROUND_NOT_NUMBER); + } + } + + public int getCount() { + return count; + } +} diff --git a/src/main/java/racingcar/service/RaceService.java b/src/main/java/racingcar/service/RaceService.java new file mode 100644 index 0000000000..72ad1ef31a --- /dev/null +++ b/src/main/java/racingcar/service/RaceService.java @@ -0,0 +1,23 @@ +package racingcar.service; + +import racingcar.domain.Car; +import racingcar.domain.Race; + +import java.util.List; + +public class RaceService { + + private final Race race; + + public RaceService(Race race) { + this.race = race; + } + + public void runSingleRound() { + race.runSingleRound(); + } + + public List getWinners() { + return race.findWinners(); + } +} diff --git a/src/main/java/racingcar/util/RandomNumberGenerator.java b/src/main/java/racingcar/util/RandomNumberGenerator.java new file mode 100644 index 0000000000..9409e7a7e3 --- /dev/null +++ b/src/main/java/racingcar/util/RandomNumberGenerator.java @@ -0,0 +1,11 @@ +package racingcar.util; + +import camp.nextstep.edu.missionutils.Randoms; +import racingcar.domain.NumberGenerator; + +public class RandomNumberGenerator implements NumberGenerator { + @Override + public int pickNumber() { + return Randoms.pickNumberInRange(0, 9); + } +} diff --git a/src/main/java/racingcar/view/Input.java b/src/main/java/racingcar/view/Input.java new file mode 100644 index 0000000000..83efb806c4 --- /dev/null +++ b/src/main/java/racingcar/view/Input.java @@ -0,0 +1,22 @@ +package racingcar.view; + +import camp.nextstep.edu.missionutils.Console; + +import java.util.Arrays; +import java.util.List; + +import static racingcar.constant.InputMessage.*; + +public class Input { + + public String inputCarNames() { + System.out.println(CAR_NAMES_INPUT_MESSAGE); + return Console.readLine(); + } + + public String inputTryCount() { + System.out.println(TRY_COUNT_INPUT_MESSAGE); + return Console.readLine(); + } + +} diff --git a/src/main/java/racingcar/view/Output.java b/src/main/java/racingcar/view/Output.java new file mode 100644 index 0000000000..5d79611c23 --- /dev/null +++ b/src/main/java/racingcar/view/Output.java @@ -0,0 +1,32 @@ +package racingcar.view; + +import racingcar.domain.Car; + +import java.util.List; +import java.util.stream.Collectors; + +import static racingcar.constant.OutputMessage.*; + +public class Output { + + public static final String DISTANCE_UNIT = "-"; + + public void printResultTitle() { + System.out.println(RESULT_TITLE); + } + + public void printCarsStatus(List cars) { + for (Car car : cars) { + System.out.println(car.getName() + " : " + DISTANCE_UNIT.repeat(car.getPosition())); + } + System.out.println(); + } + + public void printWinners(List winners) { + String winnersName = winners.stream() + .map(Car::getName) + .collect(Collectors.joining(", ")); + System.out.println(WINNER_MESSAGE + winnersName); + } + +} diff --git a/src/test/java/racingcar/domain/CarTest.java b/src/test/java/racingcar/domain/CarTest.java new file mode 100644 index 0000000000..7ca179a2a8 --- /dev/null +++ b/src/test/java/racingcar/domain/CarTest.java @@ -0,0 +1,81 @@ +package racingcar.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class CarTest { + + @Test + @DisplayName("자동차 이름이 5자 이상이면 예외가 발생한다.") + void nameTooLong_throwsException() { + assertThatThrownBy(() -> new Car("abcdef")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("자동차 이름은 5자 이하만 가능합니다."); + } + + @Test + @DisplayName("자동차 이름이 공백 문자열이면 예외가 발생한다.") + void nameBlank_throwsException() { + assertThatThrownBy(() -> new Car(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("자동차 이름은 빈 값을 입력할 수 없습니다."); + } + + @Test + @DisplayName("자동차 이름이 비어 있으면 예외가 발생한다.") + void nameEmpty_throwsException() { + assertThatThrownBy(() -> new Car("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("자동차 이름은 빈 값을 입력할 수 없습니다."); + } + + @Test + @DisplayName("자동차 이름이 유효하면 정상적으로 생성된다.") + void validName_createsCar() { + Car car = new Car("pobi"); + assertThat(car.getName()).isEqualTo("pobi"); + assertThat(car.getPosition()).isZero(); + } + + @Test + @DisplayName("랜덤 숫자가 4 이상이면 자동차가 전진한다.") + void move_whenRandomNumberGreaterOrEqual4() { + Car car = new Car("pobi"); + car.move(4); + assertThat(car.getPosition()).isEqualTo(1); + } + + @Test + @DisplayName("랜덤 숫자가 4 미만이면 자동차가 이동하지 않는다.") + void move_whenRandomNumberLessThan4() { + Car car = new Car("crong"); + car.move(3); + assertThat(car.getPosition()).isZero(); + } + + @Test + @DisplayName("자동차의 출력 문자열이 이름과 '-'로 구성된다.") + void toResultString_formatCheck() { + Car car = new Car("pobi"); + car.move(5); // 전진 + assertThat(car.toResultString()).isEqualTo("pobi : -"); + } + + @Test + @DisplayName("자동차의 위치가 최대 위치와 같으면 우승자이다.") + void isWinner_true() { + Car car = new Car("pobi"); + car.move(5); + assertThat(car.isWinner(1)).isTrue(); + } + + @Test + @DisplayName("자동차의 위치가 최대 위치와 다르면 우승자가 아니다.") + void isWinner_false() { + Car car = new Car("crong"); + car.move(2); + assertThat(car.isWinner(3)).isFalse(); + } +} diff --git a/src/test/java/racingcar/domain/RoundsTest.java b/src/test/java/racingcar/domain/RoundsTest.java new file mode 100644 index 0000000000..e6fcf9d269 --- /dev/null +++ b/src/test/java/racingcar/domain/RoundsTest.java @@ -0,0 +1,51 @@ +package racingcar.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; +import static racingcar.constant.ErrorMessage.*; + +class RoundsTest { + + @Test + @DisplayName("정상적인 숫자 문자열 입력 시 정수로 변환된다.") + void validNumberInput_parsesCorrectly() { + Rounds rounds = new Rounds("5"); + + assertThat(rounds.getCount()).isEqualTo(5); + } + + @Test + @DisplayName("음수 입력 시 예외가 발생한다.") + void negativeNumber_throwsException() { + assertThatThrownBy(() -> new Rounds("-3")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(ERROR_ROUND_NOT_NUMBER); + } + + @Test + @DisplayName("0 입력 시 예외가 발생한다.") + void zeroInput_throwsException() { + assertThatThrownBy(() -> new Rounds("0")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(ERROR_ROUND_NOT_NUMBER); + } + + @Test + @DisplayName("숫자가 아닌 문자열 입력 시 예외가 발생한다.") + void nonNumericInput_throwsException() { + assertThatThrownBy(() -> new Rounds("abc")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(ERROR_ROUND_NOT_POSITIVE); + } + + @Test + @DisplayName("공백 입력 시 예외가 발생한다.") + void blankInput_throwsException() { + assertThatThrownBy(() -> new Rounds(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(ERROR_ROUND_NOT_POSITIVE); + } +} + diff --git a/src/test/java/racingcar/fake/FakeNumberGenerator.java b/src/test/java/racingcar/fake/FakeNumberGenerator.java new file mode 100644 index 0000000000..e16e8dfe63 --- /dev/null +++ b/src/test/java/racingcar/fake/FakeNumberGenerator.java @@ -0,0 +1,16 @@ +package racingcar.fake; + +import racingcar.domain.NumberGenerator; + +public class FakeNumberGenerator implements NumberGenerator { + private final int fixedNumber; + + public FakeNumberGenerator(int fixedNumber) { + this.fixedNumber = fixedNumber; + } + + @Override + public int pickNumber() { + return fixedNumber; + } +}