diff --git a/README.md b/README.md index d0286c859f..871a61aef3 100644 --- a/README.md +++ b/README.md @@ -1 +1,34 @@ # java-racingcar-precourse + +## 기능 목록 + +### 1. 자동차 +- [x] 자동차 이름은 1~5자 조건을 지키며 생성된다. +- [x] 자동차는 이름과 위치를 보관하고, 이동 가능할 때 위치가 1 증가한다. +- [x] 여러 대의 자동차를 묶어 이동시키고 최대 위치를 확인한다. + +### 2. 이동 규칙 +- [x] 난수 생성값이 4 이상일 때 이동을 허용한다. + +### 3. 경주 게임 +- [x] 입력된 시도 횟수만큼 라운드를 진행한다. +- [x] 각 라운드에서 모든 자동차가 한 번씩 이동을 시도한다. +- [x] 가장 멀리 전진한 자동차가 우승자가 되며, 동일 최대 위치면 공동 우승자를 반환한다. + +### 4. 입력 +- [ ] 안내 문구를 명세와 동일하게 출력한 뒤 자동차 이름을 입력받는다. +- [ ] 안내 문구를 출력한 뒤 시도 횟수를 입력받는다. +- [ ] 입력된 문자열은 1차 검증을 수행한다. +- [ ] 검증된 문자열을 도메인 객체로 변환한다. + +### 5. 검증 +- [ ] 자동차 수는 2대 이상이어야 한다. +- [ ] 시도 횟수는 숫자만으로 이루어진 1 이상의 정수여야 한다. +- [ ] 잘못된 입력이 들어오면 예외를 발생시킨 후 애플리케이션을 종료한다. + +### 6. 출력 +- [ ] 횟수가 입력된 이후 '실행 결과' 문구를 출력한다. +- [ ] 각 라운드마다 모든 자동차의 현재 위치를 출력 명세의 '차수별 실행 결과' 형식에 맞게 출력한다. +- [ ] 라운드와 라운드 사이에 빈 줄을 한 줄 출력한다. +- [ ] 경주 종료 후 최종 우승자를 출력한다. +- [x] 출력 시 우승자 이름은 입력 순서를 유지한다. diff --git a/src/main/java/racingcar/Application.java b/src/main/java/racingcar/Application.java index a17a52e724..7afd0fd033 100644 --- a/src/main/java/racingcar/Application.java +++ b/src/main/java/racingcar/Application.java @@ -1,7 +1,21 @@ package racingcar; -public class Application { +import racingcar.controller.RacingGameController; +import racingcar.domain.strategy.MoveStrategy; +import racingcar.domain.strategy.RandomMoveStrategy; +import racingcar.validator.InputValidator; +import racingcar.view.InputView; +import racingcar.view.OutputView; +import racingcar.view.RacingConsole; + +public final class Application { public static void main(String[] args) { - // TODO: 프로그램 구현 + InputValidator validator = new InputValidator(); + InputView inputView = new InputView(validator); + OutputView outputView = new OutputView(); + RacingConsole console = new RacingConsole(inputView, outputView); + MoveStrategy moveStrategy = new RandomMoveStrategy(); + RacingGameController controller = new RacingGameController(console, moveStrategy); + controller.run(); } } diff --git a/src/main/java/racingcar/controller/RacingGameController.java b/src/main/java/racingcar/controller/RacingGameController.java new file mode 100644 index 0000000000..b2d237f9a2 --- /dev/null +++ b/src/main/java/racingcar/controller/RacingGameController.java @@ -0,0 +1,37 @@ +package racingcar.controller; + +import java.util.List; +import racingcar.domain.car.CarName; +import racingcar.domain.car.Cars; +import racingcar.domain.game.RacingGame; +import racingcar.domain.round.RaceRound; +import racingcar.domain.strategy.MoveStrategy; +import racingcar.view.RacingConsole; + +public final class RacingGameController { + private final RacingConsole console; + private final MoveStrategy moveStrategy; + + public RacingGameController(RacingConsole console, MoveStrategy moveStrategy) { + this.console = console; + this.moveStrategy = moveStrategy; + } + + public void run() { + try { + List carNames = console.readCarNames(); + RaceRound raceRound = console.readRaceRound(); + + Cars cars = Cars.fromNames(carNames); + RacingGame racingGame = new RacingGame(cars, moveStrategy, raceRound); + + console.printResultHeader(); + List> snapshots = racingGame.play(); + snapshots.forEach(console::printRound); + console.printWinners(racingGame.winnerNames()); + } catch (IllegalArgumentException exception) { + console.printError(exception.getMessage()); + throw exception; + } + } +} diff --git a/src/main/java/racingcar/domain/car/Car.java b/src/main/java/racingcar/domain/car/Car.java new file mode 100644 index 0000000000..6fe9716900 --- /dev/null +++ b/src/main/java/racingcar/domain/car/Car.java @@ -0,0 +1,27 @@ +package racingcar.domain.car; + +import racingcar.domain.strategy.MoveStrategy; + +public final class Car { + private final CarName name; + private int position; + + public Car(CarName name) { + this.name = name; + this.position = 0; + } + + public String name() { + return name.value(); + } + + public int position() { + return position; + } + + public void move(MoveStrategy strategy) { + if (strategy.canMove()) { + position++; + } + } +} diff --git a/src/main/java/racingcar/domain/car/CarName.java b/src/main/java/racingcar/domain/car/CarName.java new file mode 100644 index 0000000000..e3f0a3d5b6 --- /dev/null +++ b/src/main/java/racingcar/domain/car/CarName.java @@ -0,0 +1,36 @@ +package racingcar.domain.car; + +import racingcar.view.UiText; + +public class CarName { + private static final int MAX_LENGTH = 5; + + private final String name; + + public CarName(String rawName) { + String trimmedName = trim(rawName); + validateLength(trimmedName); + this.name = trimmedName; + } + + public String value() { + return name; + } + + private String trim(String rawName) { + if (rawName == null) { + throw new IllegalArgumentException(UiText.Error.NAME_EMPTY); + } + String trimmed = rawName.trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException(UiText.Error.NAME_EMPTY); + } + return trimmed; + } + + private void validateLength(String trimmedName) { + if (trimmedName.length() > MAX_LENGTH) { + throw new IllegalArgumentException(UiText.Error.NAME_UNVALID_LENGTH); + } + } +} diff --git a/src/main/java/racingcar/domain/car/Cars.java b/src/main/java/racingcar/domain/car/Cars.java new file mode 100644 index 0000000000..c524ef641e --- /dev/null +++ b/src/main/java/racingcar/domain/car/Cars.java @@ -0,0 +1,36 @@ +package racingcar.domain.car; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import racingcar.domain.strategy.MoveStrategy; + +public final class Cars { + private final List cars; + public Cars(List cars) { + this.cars = List.copyOf(cars); + } + + public static Cars fromNames(List carNames) { + List cars = carNames.stream() + .map(Car::new) + .collect(Collectors.toUnmodifiableList()); + return new Cars(cars); + } + + public void moveAll(MoveStrategy strategy) { + cars.forEach(car -> car.move(strategy)); + } + + public int maxPosition() { + return cars.stream() + .mapToInt(Car::position) + .max() + .orElse(0); + } + + public List cars() { + return Collections.unmodifiableList(cars); + } +} diff --git a/src/main/java/racingcar/domain/game/RacingGame.java b/src/main/java/racingcar/domain/game/RacingGame.java new file mode 100644 index 0000000000..cdc99fe606 --- /dev/null +++ b/src/main/java/racingcar/domain/game/RacingGame.java @@ -0,0 +1,40 @@ +package racingcar.domain.game; + +import java.util.ArrayList; +import java.util.List; +import racingcar.domain.car.Cars; +import racingcar.domain.round.RaceRound; +import racingcar.domain.strategy.MoveStrategy; + +public final class RacingGame { + private final Cars cars; + private final MoveStrategy moveStrategy; + private final RaceRound raceRound; + + public RacingGame(Cars cars, MoveStrategy moveStrategy, RaceRound raceRound) { + this.cars = cars; + this.moveStrategy = moveStrategy; + this.raceRound = raceRound; + } + + public List> play() { + List> snapshots = new ArrayList<>(); + for (int i = 0; i < raceRound.value(); i++) { + cars.moveAll(moveStrategy); + snapshots.add(carsSnapshot()); + } + return snapshots; + } + + public List winnerNames() { + return Winners.from(cars).names(); + } + + private List carsSnapshot() { + return cars.cars().stream() + .map(car -> new CarSnapshot(car.name(), car.position())) + .toList(); + } + + public record CarSnapshot(String name, int position) {} +} diff --git a/src/main/java/racingcar/domain/game/Winners.java b/src/main/java/racingcar/domain/game/Winners.java new file mode 100644 index 0000000000..1ea743e86b --- /dev/null +++ b/src/main/java/racingcar/domain/game/Winners.java @@ -0,0 +1,26 @@ +package racingcar.domain.game; + +import java.util.List; +import racingcar.domain.car.Car; +import racingcar.domain.car.Cars; + +public final class Winners { + private final List names; + + private Winners(List names) { + this.names = List.copyOf(names); + } + + public static Winners from(Cars cars) { + int maxPosition = cars.maxPosition(); + List names = cars.cars().stream() + .filter(car -> car.position() == maxPosition) + .map(Car::name) + .toList(); + return new Winners(names); + } + + public List names() { + return names; + } +} diff --git a/src/main/java/racingcar/domain/round/RaceRound.java b/src/main/java/racingcar/domain/round/RaceRound.java new file mode 100644 index 0000000000..2853674064 --- /dev/null +++ b/src/main/java/racingcar/domain/round/RaceRound.java @@ -0,0 +1,24 @@ +package racingcar.domain.round; + +import racingcar.view.UiText; + +public final class RaceRound { + private static final int MIN_ATTEMPT_COUNT = 1; + + private final int value; + + private RaceRound(int value) { + if (value < MIN_ATTEMPT_COUNT) { + throw new IllegalArgumentException(UiText.Error.ATTEMPT_TOO_SMALL); + } + this.value = value; + } + + public static RaceRound of(int value) { + return new RaceRound(value); + } + + public int value() { + return value; + } +} diff --git a/src/main/java/racingcar/domain/strategy/MoveStrategy.java b/src/main/java/racingcar/domain/strategy/MoveStrategy.java new file mode 100644 index 0000000000..761b4125b5 --- /dev/null +++ b/src/main/java/racingcar/domain/strategy/MoveStrategy.java @@ -0,0 +1,6 @@ +package racingcar.domain.strategy; + +@FunctionalInterface +public interface MoveStrategy { + boolean canMove(); +} \ No newline at end of file diff --git a/src/main/java/racingcar/domain/strategy/RandomMoveStrategy.java b/src/main/java/racingcar/domain/strategy/RandomMoveStrategy.java new file mode 100644 index 0000000000..630a19839d --- /dev/null +++ b/src/main/java/racingcar/domain/strategy/RandomMoveStrategy.java @@ -0,0 +1,15 @@ +package racingcar.domain.strategy; + +import camp.nextstep.edu.missionutils.Randoms; + +public class RandomMoveStrategy implements MoveStrategy { + private static final int MIN = 0; + private static final int MAX = 9; + private static final int MIN_MOVE_VALUE = 4; + + @Override + public boolean canMove() { + int randomNumber = Randoms.pickNumberInRange(MIN, MAX); + return randomNumber >= MIN_MOVE_VALUE; + } +} diff --git a/src/main/java/racingcar/validator/InputValidator.java b/src/main/java/racingcar/validator/InputValidator.java new file mode 100644 index 0000000000..1643778e40 --- /dev/null +++ b/src/main/java/racingcar/validator/InputValidator.java @@ -0,0 +1,57 @@ +package racingcar.validator; + +import java.util.Arrays; +import java.util.List; +import java.util.regex.Pattern; + +import racingcar.view.UiText; + +public final class InputValidator { + private static final Pattern NUMBER_PATTERN = Pattern.compile("\\d+"); + + public List validateCarNames(String rawInput) { + String trimmedInput = trim(rawInput); + List names = splitByComma(trimmedInput); + ensureNoEmptyName(names); + return names; + } + + public int validateAttemptCount(String rawInput) { + String trimmedInput = trim(rawInput); + ensureNumeric(trimmedInput); + int attemptCount = Integer.parseInt(trimmedInput); + if (attemptCount < 1) { + throw new IllegalArgumentException(UiText.Error.ATTEMPT_TOO_SMALL); + } + return attemptCount; + } + + private String trim(String input) { + if (input == null) { + throw new IllegalArgumentException(UiText.Error.EMPTY_INPUT); + } + String trimmed = input.trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException(UiText.Error.EMPTY_INPUT); + } + return trimmed; + } + + private List splitByComma(String input) { + return Arrays.stream(input.split(",")) + .map(String::trim) + .toList(); + } + + private void ensureNoEmptyName(List names) { + if (names.isEmpty() || names.stream().anyMatch(String::isEmpty)) { + throw new IllegalArgumentException(UiText.Error.NAME_EMPTY); + } + } + + private void ensureNumeric(String input) { + if (!NUMBER_PATTERN.matcher(input).matches()) { + throw new IllegalArgumentException(UiText.Error.NOT_NUMERIC); + } + } +} diff --git a/src/main/java/racingcar/view/InputView.java b/src/main/java/racingcar/view/InputView.java new file mode 100644 index 0000000000..8b958e0793 --- /dev/null +++ b/src/main/java/racingcar/view/InputView.java @@ -0,0 +1,29 @@ +package racingcar.view; + +import camp.nextstep.edu.missionutils.Console; +import java.util.List; +import racingcar.domain.car.CarName; +import racingcar.domain.round.RaceRound; +import racingcar.validator.InputValidator; + +public final class InputView { + private final InputValidator validator; + + public InputView(InputValidator validator) { + this.validator = validator; + } + + public List readCarNames() { + String input = Console.readLine(); + List names = validator.validateCarNames(input); + return names.stream() + .map(CarName::new) + .toList(); + } + + public RaceRound readRaceRound() { + String input = Console.readLine(); + int count = validator.validateAttemptCount(input); + return RaceRound.of(count); + } +} diff --git a/src/main/java/racingcar/view/OutputView.java b/src/main/java/racingcar/view/OutputView.java new file mode 100644 index 0000000000..b5e873eb47 --- /dev/null +++ b/src/main/java/racingcar/view/OutputView.java @@ -0,0 +1,34 @@ +package racingcar.view; + +import java.util.List; +import racingcar.domain.game.RacingGame; + +public final class OutputView { + + public void printResultHeader() { + System.out.println(); + System.out.println(UiText.Output.EXECUTION_RESULT_HEADER); + } + + public void printCarNamesPrompt() { + System.out.println(UiText.Prompt.CAR_NAMES); + } + + public void printRaceRoundPrompt() { + System.out.println(UiText.Prompt.ATTEMPT_COUNT); + } + + public void printRound(List snapshots) { + snapshots.forEach(snapshot -> System.out.println(snapshot.name() + " : " + + UiText.Output.POSITION_MARK.repeat(snapshot.position()))); + System.out.println(); + } + + public void printWinners(List winners) { + System.out.println(UiText.Output.WINNER_PREFIX + String.join(", ", winners)); + } + + public void printError(String message) { + System.out.println(UiText.Error.PREFIX + message); + } +} diff --git a/src/main/java/racingcar/view/RacingConsole.java b/src/main/java/racingcar/view/RacingConsole.java new file mode 100644 index 0000000000..8d2b8c315f --- /dev/null +++ b/src/main/java/racingcar/view/RacingConsole.java @@ -0,0 +1,42 @@ +package racingcar.view; + +import java.util.List; +import racingcar.domain.car.CarName; +import racingcar.domain.game.RacingGame; +import racingcar.domain.round.RaceRound; + +public final class RacingConsole { + private final InputView inputView; + private final OutputView outputView; + + public RacingConsole(InputView inputView, OutputView outputView) { + this.inputView = inputView; + this.outputView = outputView; + } + + public List readCarNames() { + outputView.printCarNamesPrompt(); + return inputView.readCarNames(); + } + + public RaceRound readRaceRound() { + outputView.printRaceRoundPrompt(); + return inputView.readRaceRound(); + } + + public void printResultHeader() { + outputView.printResultHeader(); + } + + public void printRound(List snapshots) { + outputView.printRound(snapshots); + } + + public void printWinners(List winners) { + outputView.printWinners(winners); + } + + public void printError(String message) { + outputView.printError(message); + } +} diff --git a/src/main/java/racingcar/view/UiText.java b/src/main/java/racingcar/view/UiText.java new file mode 100644 index 0000000000..96d17927e5 --- /dev/null +++ b/src/main/java/racingcar/view/UiText.java @@ -0,0 +1,36 @@ +package racingcar.view; + +public final class UiText { + private UiText() {} + + public static final class Prompt { + private Prompt() {} + + public static final String CAR_NAMES = + "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"; + public static final String ATTEMPT_COUNT = + "시도할 횟수는 몇 회인가요?"; + } + + public static final class Output { + private Output() {} + + public static final String EXECUTION_RESULT_HEADER = "실행 결과"; + public static final String WINNER_PREFIX = "최종 우승자 : "; + public static final String POSITION_MARK = "-"; + } + + public static final class Error { + private Error() {} + + public static final String PREFIX = "[ERROR] "; + public static final String UNKNOWN = "알 수 없는 오류가 발생했습니다."; + public static final String EMPTY_INPUT = "입력값은 비어 있을 수 없습니다."; + public static final String NOT_NUMERIC = "시도 횟수는 숫자여야 합니다."; + public static final String ATTEMPT_TOO_SMALL = "시도 횟수는 1 이상의 정수여야 합니다."; + public static final String NAME_EMPTY = "자동차 이름은 비어 있을 수 없습니다."; + public static final String NAME_UNVALID_LENGTH = "자동차 이름은 1자 이상 5자 이하여야 합니다."; + public static final String NAME_DUPLICATED = "자동차 이름은 중복될 수 없습니다."; + public static final String CAR_COUNT_TOO_SMALL = "경주에는 최소 2대 이상의 자동차가 필요합니다."; + } +} diff --git a/src/test/java/racingcar/controller/RacingGameControllerTest.java b/src/test/java/racingcar/controller/RacingGameControllerTest.java new file mode 100644 index 0000000000..b0a5ec247f --- /dev/null +++ b/src/test/java/racingcar/controller/RacingGameControllerTest.java @@ -0,0 +1,55 @@ +package racingcar.controller; + +import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import camp.nextstep.edu.missionutils.test.NsTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import racingcar.domain.strategy.MoveStrategy; +import racingcar.validator.InputValidator; +import racingcar.view.InputView; +import racingcar.view.OutputView; +import racingcar.view.RacingConsole; +import racingcar.view.UiText; + +class RacingGameControllerTest extends NsTest { + private static RacingGameController controller; + + @Test + @DisplayName("전체 경주 흐름을 실행한다") + void runGame() { + assertSimpleTest(() -> { + run("pobi,woni", "3"); + + assertThat(output()) + .contains(UiText.Prompt.CAR_NAMES) + .contains(UiText.Prompt.ATTEMPT_COUNT) + .contains(UiText.Output.EXECUTION_RESULT_HEADER) + .contains("pobi : ---") + .contains("woni : ---") + .contains(UiText.Output.WINNER_PREFIX + "pobi, woni"); + }); + } + + @Test + @DisplayName("잘못된 입력이 들어오면 에러 메시지를 출력하고 예외를 던진다") + void invalidInputPrintsError() { + assertSimpleTest(() -> { + assertThatThrownBy(() -> runException("pobi,,woni")) + .isInstanceOf(IllegalArgumentException.class); + assertThat(output()).contains(UiText.Error.PREFIX); + }); + } + + @Override + public void runMain() { + InputView inputView = new InputView(new InputValidator()); + OutputView outputView = new OutputView(); + RacingConsole console = new RacingConsole(inputView, outputView); + MoveStrategy alwaysMove = () -> true; + controller = new RacingGameController(console, alwaysMove); + controller.run(); + } +} diff --git a/src/test/java/racingcar/domain/car/CarNameTest.java b/src/test/java/racingcar/domain/car/CarNameTest.java new file mode 100644 index 0000000000..82a9b644b6 --- /dev/null +++ b/src/test/java/racingcar/domain/car/CarNameTest.java @@ -0,0 +1,55 @@ +package racingcar.domain.car; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import racingcar.view.UiText; + +class CarNameTest { + + @Test + @DisplayName("이름이 null이면 예외를 던진다") + void throwExceptionWhenNameNull() { + assertThatThrownBy(() -> new CarName(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(UiText.Error.NAME_EMPTY); + } + + @Test + @DisplayName("이름이 비어 있거나 공백뿐이면 예외를 던진다") + void throwExceptionWhenNameBlank() { + assertThatThrownBy(() -> new CarName("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(UiText.Error.NAME_EMPTY); + assertThatThrownBy(() -> new CarName(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(UiText.Error.NAME_EMPTY); + } + + @Test + @DisplayName("이름 길이가 5자를 초과하면 예외를 던진다") + void throwExceptionWhenNameTooLong() { + assertThatThrownBy(() -> new CarName("abcdef")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(UiText.Error.NAME_UNVALID_LENGTH); + } + + @Test + @DisplayName("앞뒤 공백은 제거된 상태로 저장된다") + void trimName() { + CarName carName = new CarName(" pobi "); + + assertThat(carName.value()).isEqualTo("pobi"); + } + + @Test + @DisplayName("1~5자의 이름은 생성된다") + void createValidName() { + assertThat(new CarName("a").value()).isEqualTo("a"); + assertThat(new CarName("pobi").value()).isEqualTo("pobi"); + assertThat(new CarName("junho").value()).isEqualTo("junho"); + assertThat(new CarName("pobi ").value()).isEqualTo("pobi"); + } +} diff --git a/src/test/java/racingcar/domain/car/CarTest.java b/src/test/java/racingcar/domain/car/CarTest.java new file mode 100644 index 0000000000..3a55f9f5b5 --- /dev/null +++ b/src/test/java/racingcar/domain/car/CarTest.java @@ -0,0 +1,40 @@ +package racingcar.domain.car; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import racingcar.domain.strategy.MoveStrategy; + +class CarTest { + + @Test + @DisplayName("자동차는 생성 시 위치가 0이다") + void carInitialPositionIsZero() { + Car car = new Car(new CarName("pobi")); + + assertThat(car.position()).isZero(); + } + + @Test + @DisplayName("이동 전략이 true를 반환하면 한 칸 전진한다") + void carMovesWhenStrategyAllows() { + Car car = new Car(new CarName("pobi")); + MoveStrategy moveAlways = () -> true; + + car.move(moveAlways); + + assertThat(car.position()).isOne(); + } + + @Test + @DisplayName("이동 전략이 false를 반환하면 제자리다") + void carDoesNotMoveWhenStrategyBlocks() { + Car car = new Car(new CarName("pobi")); + MoveStrategy stayStill = () -> false; + + car.move(stayStill); + + assertThat(car.position()).isZero(); + } +} diff --git a/src/test/java/racingcar/domain/car/CarsTest.java b/src/test/java/racingcar/domain/car/CarsTest.java new file mode 100644 index 0000000000..d5230677df --- /dev/null +++ b/src/test/java/racingcar/domain/car/CarsTest.java @@ -0,0 +1,59 @@ +package racingcar.domain.car; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import racingcar.domain.strategy.MoveStrategy; + +class CarsTest { + + @Test + @DisplayName("모든 자동차가 이동 전략에 따라 이동한다") + void moveAll() { + Cars cars = Cars.fromNames(List.of(new CarName("pobi"), new CarName("woni"))); + Iterator iterator = Arrays.asList(true, false).iterator(); + MoveStrategy alternatingStrategy = iterator::next; + + cars.moveAll(alternatingStrategy); + + List actual = cars.cars(); + assertThat(actual.get(0).position()).isEqualTo(1); + assertThat(actual.get(1).position()).isEqualTo(0); + } + + @Test + @DisplayName("최대 위치를 계산한다") + void maxPosition() { + Cars cars = Cars.fromNames(List.of( + new CarName("pobi"), + new CarName("woni"), + new CarName("jun")) + ); + + MoveStrategy alwaysMove = () -> true; + + cars.moveAll(alwaysMove); // 모두 1칸 + cars.moveAll(new MoveStrategy() { // 첫 번째만 이동 + private final Iterator iterator = Arrays.asList(true, false, false).iterator(); + @Override + public boolean canMove() { + return iterator.hasNext() && iterator.next(); + } + }); + cars.moveAll(new MoveStrategy() { // 두 번째만 이동 + private final Iterator iterator = Arrays.asList(false, true, false).iterator(); + @Override + public boolean canMove() { + return iterator.hasNext() && iterator.next(); + } + }); + + assertThat(cars.maxPosition()).isEqualTo(2); + } + +} diff --git a/src/test/java/racingcar/domain/game/RacingGameTest.java b/src/test/java/racingcar/domain/game/RacingGameTest.java new file mode 100644 index 0000000000..47b9a0feb5 --- /dev/null +++ b/src/test/java/racingcar/domain/game/RacingGameTest.java @@ -0,0 +1,48 @@ +package racingcar.domain.game; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import racingcar.domain.car.CarName; +import racingcar.domain.car.Cars; +import racingcar.domain.round.RaceRound; +import racingcar.domain.strategy.MoveStrategy; + +class RacingGameTest { + + @Test + @DisplayName("지정된 횟수만큼 라운드를 진행하고 결과를 반환한다") + void playCollectsRoundResults() { + Cars cars = Cars.fromNames(List.of(new CarName("pobi"), new CarName("woni"))); + MoveStrategy alwaysMove = () -> true; + RacingGame racingGame = new RacingGame(cars, alwaysMove, RaceRound.of(3)); + + List> results = racingGame.play(); + + assertThat(results).hasSize(3); + assertThat(results.get(2)).extracting(RacingGame.CarSnapshot::position) + .containsExactly(3, 3); + } + + @Test + @DisplayName("우승자 이름을 반환한다") + void winnerNames() { + Cars cars = Cars.fromNames(List.of(new CarName("pobi"), new CarName("woni"))); + MoveStrategy strategy = new MoveStrategy() { + private int counter; + + @Override + public boolean canMove() { + return counter++ % 2 == 0; + } + }; + RacingGame racingGame = new RacingGame(cars, strategy, RaceRound.of(2)); + + racingGame.play(); + + List winners = racingGame.winnerNames(); + assertThat(winners).containsExactly("pobi"); + } +} diff --git a/src/test/java/racingcar/domain/game/WinnersTest.java b/src/test/java/racingcar/domain/game/WinnersTest.java new file mode 100644 index 0000000000..23c44707a8 --- /dev/null +++ b/src/test/java/racingcar/domain/game/WinnersTest.java @@ -0,0 +1,47 @@ +package racingcar.domain.game; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Iterator; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import racingcar.domain.car.CarName; +import racingcar.domain.car.Cars; +import racingcar.domain.strategy.MoveStrategy; + +class WinnersTest { + + @Test + @DisplayName("우승자 목록을 입력 순서대로 반환한다") + void returnWinnersInInputOrder() { + Cars cars = Cars.fromNames(List.of( + new CarName("pobi"), + new CarName("woni"), + new CarName("jun") + )); + + MoveStrategy firstOnly = new MoveStrategy() { + private final Iterator iterator = List.of(true, false, false).iterator(); + @Override + public boolean canMove() { + return iterator.hasNext() && iterator.next(); + } + }; + MoveStrategy secondOnly = new MoveStrategy() { + private final Iterator iterator = List.of(false, true, false).iterator(); + @Override + public boolean canMove() { + return iterator.hasNext() && iterator.next(); + } + }; + + cars.moveAll(() -> true); // 모두 1칸 + cars.moveAll(firstOnly); // 첫 번째만 이동 -> pobi 2, woni 1 + cars.moveAll(secondOnly); // 두 번째만 이동 -> pobi 2, woni 2 + + Winners winners = Winners.from(cars); + + assertThat(winners.names()).containsExactly("pobi", "woni"); + } +} diff --git a/src/test/java/racingcar/domain/round/RaceRoundTest.java b/src/test/java/racingcar/domain/round/RaceRoundTest.java new file mode 100644 index 0000000000..b8b8e769da --- /dev/null +++ b/src/test/java/racingcar/domain/round/RaceRoundTest.java @@ -0,0 +1,25 @@ +package racingcar.domain.round; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RaceRoundTest { + + @Test + @DisplayName("시도 횟수가 1 이상이면 생성된다") + void createRaceRound() { + RaceRound round = RaceRound.of(3); + + assertThat(round.value()).isEqualTo(3); + } + + @Test + @DisplayName("시도 횟수가 1 미만이면 예외") + void throwWhenLessThanOne() { + assertThatThrownBy(() -> RaceRound.of(0)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/racingcar/validator/InputValidatorTest.java b/src/test/java/racingcar/validator/InputValidatorTest.java new file mode 100644 index 0000000000..c12861ac20 --- /dev/null +++ b/src/test/java/racingcar/validator/InputValidatorTest.java @@ -0,0 +1,61 @@ +package racingcar.validator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import racingcar.view.UiText; + +class InputValidatorTest { + private final InputValidator validator = new InputValidator(); + + @Test + @DisplayName("쉼표로 구분된 자동차 이름을 공백 제거 후 반환한다") + void validateCarNames() { + List names = validator.validateCarNames(" pobi , woni , jun "); + + assertThat(names).containsExactly("pobi", "woni", "jun"); + } + + @Test + @DisplayName("자동차 이름 입력이 비어 있으면 예외") + void validateCarNamesBlank() { + assertThatThrownBy(() -> validator.validateCarNames(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(UiText.Error.EMPTY_INPUT); + } + + @Test + @DisplayName("자동차 이름 중 비어 있는 항목이 있으면 예외") + void validateCarNamesContainsBlank() { + assertThatThrownBy(() -> validator.validateCarNames("pobi, ,woni")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(UiText.Error.NAME_EMPTY); + } + + @Test + @DisplayName("시도 횟수는 숫자 문자열이어야 한다") + void validateAttemptCountNumeric() { + int count = validator.validateAttemptCount(" 10 "); + + assertThat(count).isEqualTo(10); + } + + @Test + @DisplayName("시도 횟수가 숫자가 아니면 예외") + void validateAttemptCountNotNumeric() { + assertThatThrownBy(() -> validator.validateAttemptCount("1a")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(UiText.Error.NOT_NUMERIC); + } + + @Test + @DisplayName("시도 횟수가 1 미만이면 예외") + void validateAttemptCountTooSmall() { + assertThatThrownBy(() -> validator.validateAttemptCount("0")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(UiText.Error.ATTEMPT_TOO_SMALL); + } +} diff --git a/src/test/java/racingcar/view/InputViewTest.java b/src/test/java/racingcar/view/InputViewTest.java new file mode 100644 index 0000000000..cfb05fac7c --- /dev/null +++ b/src/test/java/racingcar/view/InputViewTest.java @@ -0,0 +1,51 @@ +package racingcar.view; + +import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import camp.nextstep.edu.missionutils.test.NsTest; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import racingcar.domain.car.CarName; +import racingcar.domain.round.RaceRound; +import racingcar.validator.InputValidator; + +class InputViewTest extends NsTest { + private final InputView inputView = new InputView(new InputValidator()); + private static List capturedNames; + private static RaceRound capturedRound; + + @Test + @DisplayName("자동차 이름과 시도 횟수를 순서대로 입력받는다") + void readInputs() { + assertSimpleTest(() -> { + run("pobi,woni,jun", "5"); + + assertThat(capturedNames).extracting(CarName::value) + .containsExactly("pobi", "woni", "jun"); + assertThat(capturedRound.value()).isEqualTo(5); + }); + } + + @Test + @DisplayName("잘못된 입력은 예외를 발생시킨다") + void invalidInputThrowsException() { + assertSimpleTest(() -> + assertThatThrownBy(() -> runException("pobi,,woni")) + .isInstanceOf(IllegalArgumentException.class) + ); + + assertSimpleTest(() -> + assertThatThrownBy(() -> runException("pobi,woni", "a")) + .isInstanceOf(IllegalArgumentException.class) + ); + } + + @Override + public void runMain() { + capturedNames = inputView.readCarNames(); + capturedRound = inputView.readRaceRound(); + } +} diff --git a/src/test/java/racingcar/view/OutputViewTest.java b/src/test/java/racingcar/view/OutputViewTest.java new file mode 100644 index 0000000000..1e2760e14e --- /dev/null +++ b/src/test/java/racingcar/view/OutputViewTest.java @@ -0,0 +1,38 @@ +package racingcar.view; + +import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest; +import static org.assertj.core.api.Assertions.assertThat; + +import camp.nextstep.edu.missionutils.test.NsTest; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import racingcar.domain.game.RacingGame; + +class OutputViewTest extends NsTest { + private final OutputView outputView = new OutputView(); + + @Test + @DisplayName("라운드 결과와 우승자를 지정된 형식으로 출력한다") + void printRoundAndWinners() { + assertSimpleTest(() -> { + run(); + assertThat(output()).contains( + UiText.Output.EXECUTION_RESULT_HEADER, + "pobi : --", + "woni : -", + UiText.Output.WINNER_PREFIX + "pobi, woni" + ); + }); + } + + @Override + public void runMain() { + outputView.printResultHeader(); + outputView.printRound(List.of( + new RacingGame.CarSnapshot("pobi", 2), + new RacingGame.CarSnapshot("woni", 1) + )); + outputView.printWinners(List.of("pobi", "woni")); + } +} diff --git a/src/test/java/racingcar/view/RacingConsoleTest.java b/src/test/java/racingcar/view/RacingConsoleTest.java new file mode 100644 index 0000000000..527eeb7c7e --- /dev/null +++ b/src/test/java/racingcar/view/RacingConsoleTest.java @@ -0,0 +1,52 @@ +package racingcar.view; + +import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest; +import static org.assertj.core.api.Assertions.assertThat; + +import camp.nextstep.edu.missionutils.test.NsTest; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import racingcar.domain.car.CarName; +import racingcar.domain.game.RacingGame; +import racingcar.domain.round.RaceRound; +import racingcar.validator.InputValidator; + +class RacingConsoleTest extends NsTest { + private final InputView inputView = new InputView(new InputValidator()); + private final OutputView outputView = new OutputView(); + private final RacingConsole console = new RacingConsole(inputView, outputView); + + private static List capturedNames; + private static RaceRound capturedRound; + + @Test + @DisplayName("입출력 파사드가 흐름을 위임한다") + void delegateToViews() { + assertSimpleTest(() -> { + run("pobi,woni", "3"); + + assertThat(capturedNames).extracting(CarName::value) + .containsExactly("pobi", "woni"); + assertThat(capturedRound.value()).isEqualTo(3); + assertThat(output()) + .contains(UiText.Prompt.CAR_NAMES) + .contains(UiText.Prompt.ATTEMPT_COUNT) + .contains(UiText.Output.EXECUTION_RESULT_HEADER) + .contains("pobi : --") + .contains("최종 우승자 : pobi, woni"); + }); + } + + @Override + public void runMain() { + capturedNames = console.readCarNames(); + capturedRound = console.readRaceRound(); + console.printResultHeader(); + console.printRound(List.of( + new RacingGame.CarSnapshot("pobi", 2), + new RacingGame.CarSnapshot("woni", 1) + )); + console.printWinners(List.of("pobi", "woni")); + } +}