diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..af92247 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,40 @@ +## view +#### 입력 + +- 자동차 이름을 입력받는다. + - 5자 이하만 가능하다. +- 시도할 횟수를 입력받는다. + +#### 출력 + +- 회차 별로 실행 결과를 출력한다. +- 하나 이상의 우승자를 출력한다. + +## domain +#### Car + +- 자동차 이름 +- 자동차 위치 +- 자동차 움직임 + +#### Cars + +- 모든 자동차의 정보를 가진다. + +#### Name +- 사용할 수 있는 이름인지 검증한다. + +#### AttemptNumber + +- 시도 가능한 횟수가 몇 번 남았는지 나타낸다. + +#### MoveCar + +- 0에서 9 사이에서 random 값을 구한 후, + random 값이 4 이상일 경우 전진하고, 3 이하의 값이면 멈춘다. + + + + +## controller +- 전체적인 진행을 관리한다. \ No newline at end of file diff --git a/src/main/java/RacingMain.java b/src/main/java/RacingMain.java index 4394287..91c0953 100644 --- a/src/main/java/RacingMain.java +++ b/src/main/java/RacingMain.java @@ -1,7 +1,10 @@ -public class RacingMain { +import controller.RacingCarController; + +import java.io.IOException; - public static void main(String[] args) { - // TODO: MVC 패턴을 기반으로 자동차 경주 미션 구현해보기 - System.out.println("Hello, World!"); +public class RacingMain { + public static void main(String[] args) throws IOException { + RacingCarController racingCarController = new RacingCarController(); + racingCarController.run(); } } diff --git a/src/main/java/controller/RacingCarController.java b/src/main/java/controller/RacingCarController.java new file mode 100644 index 0000000..4039ec4 --- /dev/null +++ b/src/main/java/controller/RacingCarController.java @@ -0,0 +1,67 @@ +package controller; + +import domain.AttemptNumber; +import domain.Cars; +import domain.RandomNumberGenerator; +import dto.CarDto; +import view.InputView; +import view.OutputView; + +import java.io.IOException; +import java.util.List; + +public class RacingCarController { + + private final RandomNumberGenerator randomNumberGenerator; + + public RacingCarController() { + this.randomNumberGenerator = new RandomNumberGenerator(); + } + + public void run() throws IOException { + Cars cars = getCars(); + AttemptNumber attemptNumber = getAttemptNumber(); + race(cars, attemptNumber); + printWinners(cars); + } + + private Cars getCars() throws IOException { + List carNames = InputView.readCarNames(); + try { + return Cars.from(carNames); + } catch (IllegalArgumentException e) { + System.out.println(e.getMessage()); + return getCars(); + } + } + + private AttemptNumber getAttemptNumber() throws IOException { + try { + int number = InputView.readAttemptNumber(); + return new AttemptNumber(number); + } catch (IllegalArgumentException e) { + System.out.println(e.getMessage()); + return getAttemptNumber(); + } + } + + private void race(final Cars cars, final AttemptNumber attemptNumber) throws IOException { + OutputView.printResult(); + while (attemptNumber.isRemain()) { + attemptNumber.decrease(); + cars.moveAll(randomNumberGenerator); + printStatus(cars); + } + } + + private void printStatus(final Cars cars) { + List carDtos = CarDto.getInstances(cars); + OutputView.printStatus(carDtos); + } + + private void printWinners(final Cars cars) { + Cars winnerCars = cars.findWinners(); + List winnerCarDtos = CarDto.getInstances(winnerCars); + OutputView.printWinners(winnerCarDtos); + } +} diff --git a/src/main/java/domain/AttemptNumber.java b/src/main/java/domain/AttemptNumber.java new file mode 100644 index 0000000..c38fec2 --- /dev/null +++ b/src/main/java/domain/AttemptNumber.java @@ -0,0 +1,30 @@ +package domain; + +public class AttemptNumber { + private static final String NOT_POSITIVE_INTEGER_MESSAGE = "[ERROR] 시도 회수는 양의 정수여야 합니다."; + + private int attemptNumber; + + public AttemptNumber(int attemptNumber) { + validate(attemptNumber); + this.attemptNumber = attemptNumber; + } + + private void validate(int attemptNumber) { + if (attemptNumber <= 0) { + throw new IllegalArgumentException(NOT_POSITIVE_INTEGER_MESSAGE); + } + } + + public void decrease() { + attemptNumber--; + } + + public boolean isRemain() { + return attemptNumber != 0; + } + + public int getAttemptNumber() { + return attemptNumber; + } +} diff --git a/src/main/java/domain/Car.java b/src/main/java/domain/Car.java new file mode 100644 index 0000000..665684b --- /dev/null +++ b/src/main/java/domain/Car.java @@ -0,0 +1,36 @@ +package domain; + +public class Car { + public static final int MOVED_LOWER_BOUND = 4; + + private final Name name; + private final Position position; + + private Car(final String name, final int position) { + this.name = new Name(name); + this.position = new Position(position); + } + + public static Car from(final String name) { + return new Car(name, 0); + } + + public static Car of(final String name, final int position) { + return new Car(name, position); + } + + + public void move(final int number) { + if (number >= MOVED_LOWER_BOUND) { + position.increase(); + } + } + + public String getName() { + return name.getName(); + } + + public int getPosition() { + return position.getPosition(); + } +} diff --git a/src/main/java/domain/Cars.java b/src/main/java/domain/Cars.java new file mode 100644 index 0000000..cfc885f --- /dev/null +++ b/src/main/java/domain/Cars.java @@ -0,0 +1,48 @@ +package domain; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class Cars { + private final int DEFAULT_POSITION = 0; + + private final List cars; + + public Cars(final List cars) { + this.cars = Collections.unmodifiableList(cars); + } + + public static Cars from(final List carNames) { + List cars = carNames.stream() + .map(Car::from) + .collect(Collectors.toList()); + return new Cars(cars); + } + + public void moveAll(final RandomNumberGenerator randomNumberGenerator) { + for (Car car : cars) { + int number = randomNumberGenerator.generate(); + car.move(number); + } + } + + public Cars findWinners() { + int maxPosition = getMaxPosition(); + List winningCars = cars.stream() + .filter(car -> car.getPosition() == maxPosition) + .collect(Collectors.toList()); + return new Cars(winningCars); + } + + private int getMaxPosition() { + return cars.stream() + .mapToInt(Car::getPosition) + .max() + .orElse(DEFAULT_POSITION); + } + + public List getCars() { + return cars; + } +} diff --git a/src/main/java/domain/Name.java b/src/main/java/domain/Name.java new file mode 100644 index 0000000..5991879 --- /dev/null +++ b/src/main/java/domain/Name.java @@ -0,0 +1,26 @@ +package domain; + +public class Name { + private static final String NO_NAME_EXISTS_MESSSGE = "[ERROR] 이름은 반드시 있어야 합니다."; + private static final String INVALID_LENGTH_MESSAGE = "[ERROR] 이름은 5글자까지 가능합니다."; + + private final String name; + + public Name(final String name) { + validate(name); + this.name = name; + } + + private void validate(final String name) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException(NO_NAME_EXISTS_MESSSGE); + } + if (name.length() > 5) { + throw new IllegalArgumentException(INVALID_LENGTH_MESSAGE); + } + } + + public String getName(){ + return name; + } +} diff --git a/src/main/java/domain/Position.java b/src/main/java/domain/Position.java new file mode 100644 index 0000000..9b697eb --- /dev/null +++ b/src/main/java/domain/Position.java @@ -0,0 +1,17 @@ +package domain; + +public class Position { + private int position; + + public Position(final int position) { + this.position = position; + } + + public void increase() { + position++; + } + + public int getPosition() { + return position; + } +} diff --git a/src/main/java/domain/RandomNumberGenerator.java b/src/main/java/domain/RandomNumberGenerator.java new file mode 100644 index 0000000..45e689e --- /dev/null +++ b/src/main/java/domain/RandomNumberGenerator.java @@ -0,0 +1,7 @@ +package domain; + +public class RandomNumberGenerator { + public int generate() { + return (int)(Math.random() * 10); + } +} diff --git a/src/main/java/dto/CarDto.java b/src/main/java/dto/CarDto.java new file mode 100644 index 0000000..822bb62 --- /dev/null +++ b/src/main/java/dto/CarDto.java @@ -0,0 +1,35 @@ +package dto; + +import domain.Car; +import domain.Cars; + +import java.util.List; +import java.util.stream.Collectors; + +public class CarDto { + private final String name; + private final int position; + + private CarDto(final String name, final int position) { + this.name = name; + this.position = position; + } + + public static CarDto getInstance(final Car car) { + return new CarDto(car.getName(), car.getPosition()); + } + + public static List getInstances(final Cars cars) { + return cars.getCars().stream() + .map(CarDto::getInstance) + .collect(Collectors.toList()); + } + + public String getName() { + return name; + } + + public int getPosition() { + return position; + } +} diff --git a/src/main/java/view/InputView.java b/src/main/java/view/InputView.java new file mode 100644 index 0000000..0eb1fc7 --- /dev/null +++ b/src/main/java/view/InputView.java @@ -0,0 +1,48 @@ +package view; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Arrays; +import java.util.List; + +public class InputView { + + private static final String READ_CAR_NAME_MESSAGE = "경주할 자동차 이름을 입력하세요(이름은 쉼표(,)를 기준으로 구분)."; + private static final String READ_ATTEMPT_NUMBER_MESSAGE = "시도할 회수는 몇회인가요?"; + + private static final String NOT_INTEGER_MESSAGE = "[ERROR] 입력 값은 정수여야 합니다."; + + private static final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in)); + + public static List readCarNames() throws IOException { + System.out.println(READ_CAR_NAME_MESSAGE); + String input = bufferedReader.readLine(); + return splitByComma(input); + } + + public static List splitByComma(final String input) { + String[] split = input.split(",", -1); + return Arrays.asList(split); + } + + public static int readAttemptNumber() throws IOException { + try { + System.out.println(READ_ATTEMPT_NUMBER_MESSAGE); + String input = bufferedReader.readLine(); + return parse(input); + } catch (IllegalArgumentException e) { + System.out.println(e.getMessage()); + return readAttemptNumber(); + } + } + + public static int parse(final String input) { + try { + return Integer.parseInt(input); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(NOT_INTEGER_MESSAGE, e); + } + } + +} diff --git a/src/main/java/view/OutputView.java b/src/main/java/view/OutputView.java new file mode 100644 index 0000000..7d21b8a --- /dev/null +++ b/src/main/java/view/OutputView.java @@ -0,0 +1,40 @@ +package view; + +import dto.CarDto; + +import java.util.List; +import java.util.stream.Collectors; + +public class OutputView { + + private static final String LINE_SEPARATOR = System.lineSeparator(); + private static final String RESULT_MESSAGE = "실행 결과"; + private static final String STATUS_PRINT_FORMAT = "%s : %s" + LINE_SEPARATOR; + private static final String WINNER_PRINT_FORMAT = "%s가 최종 우승했습니다." + LINE_SEPARATOR; + private static final String WORD_DELIMITER = ", "; + private static final String POSITION_SYMBOL = "-"; + + public static void printResult() { + System.out.println(RESULT_MESSAGE); + } + + public static void printStatus(final List carDtos) { + for (CarDto carDto : carDtos) { + String currentPosition = getCurrentPosition(carDto.getPosition()); + System.out.printf(STATUS_PRINT_FORMAT, carDto.getName(), currentPosition); + } + System.out.println(); + } + + private static String getCurrentPosition(final int position) { + return POSITION_SYMBOL.repeat(Math.max(0, position)); + } + + public static void printWinners(final List carDtos) { + List carNames = carDtos.stream() + .map(CarDto::getName) + .collect(Collectors.toList()); + String winners = String.join(WORD_DELIMITER, carNames); + System.out.printf(WINNER_PRINT_FORMAT, winners); + } +} diff --git a/src/test/java/domain/AttemptNumberTest.java b/src/test/java/domain/AttemptNumberTest.java new file mode 100644 index 0000000..57dba84 --- /dev/null +++ b/src/test/java/domain/AttemptNumberTest.java @@ -0,0 +1,65 @@ +package domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.assertj.core.api.Assertions.*; + +public class AttemptNumberTest { + @DisplayName("시도 횟수가 양의 정수가 아니라면 예외를 던진다.") + @ParameterizedTest + @CsvSource({"-1", "0"}) + void should_ThrowIllegalArgumentException_When_AttemptNumberIsNotPositiveInteger(int attemptNumber) { + assertThatThrownBy(() -> new AttemptNumber(attemptNumber)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR]"); + } + + @DisplayName("시도 횟수가 양의 정수라면 예외를 던지지 않는다.") + @ParameterizedTest + @CsvSource({"1, 100"}) + void should_DoesNotThrowException_When_AttemptNumberIsPositiveInteger(int attemptNumber) { + assertThatCode(() -> new AttemptNumber(attemptNumber)) + .doesNotThrowAnyException(); + } + + @DisplayName("시도 횟수가 100 초과의 양의 정수라면 예외를 던진다.") + @ParameterizedTest + @CsvSource({"101", "10000"}) + void should_ThrowIllegalArgumentException_When_AttemptNumberIsInvalid(int attemptNumber) { + assertThatThrownBy(() -> new AttemptNumber(attemptNumber)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR]"); + } + + @DisplayName("decrease 메서드는 attemptNumber 의 값을 1 감소시킨다.") + @ParameterizedTest + @CsvSource({"1,0", "2,1", "3,2"}) + void should_DecreaseByOne_When_UsingDecreaseMethod(int beforeValue, int afterValue) { + //given + AttemptNumber attemptNumber = new AttemptNumber(beforeValue); + + //when + attemptNumber.decrease(); + + //then + assertThat(attemptNumber.getAttemptNumber()).isEqualTo(afterValue); + } + + @DisplayName("isRemain 메서드는 attemptNumber 값이 남아있는지 확인한다.") + @ParameterizedTest + @CsvSource({"1,false", "2,true"}) + void should_DecreaseByOne_When_UsingDecreaseMethod(int beforeDecrease, boolean isRemain) { + //given + AttemptNumber attemptNumber = new AttemptNumber(beforeDecrease); + + //when + attemptNumber.decrease(); + + //then + assertThat(attemptNumber.isRemain()).isEqualTo(isRemain); + } +} diff --git a/src/test/java/domain/CarTest.java b/src/test/java/domain/CarTest.java new file mode 100644 index 0000000..4e2d454 --- /dev/null +++ b/src/test/java/domain/CarTest.java @@ -0,0 +1,37 @@ +package domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.*; + +public class CarTest { + @DisplayName("0 ~ 3의 값일때는 움직이지 않는다.") + @ParameterizedTest + @CsvSource({"0, 0", "1, 0", "2, 0", "3, 0"}) + void should_Stay_When_NumberIsZeroToThree(int number, int position) { + //given + Car car = Car.from("test"); + + //when + car.move(number); + + //then + assertThat(car.getPosition()).isEqualTo(position); + } + + @DisplayName("4 ~ 9의 값일때는 움직인다.") + @ParameterizedTest + @CsvSource({"4, 1", "5, 1", "6, 1", "7, 1", "8, 1", "9, 1"}) + void should_Move_When_NumberIsFourToNine(int number, int position) { + //given + Car car = Car.from("test"); + + //when + car.move(number); + + //then + assertThat(car.getPosition()).isEqualTo(position); + } +} diff --git a/src/test/java/domain/CarsTest.java b/src/test/java/domain/CarsTest.java new file mode 100644 index 0000000..d14ffc9 --- /dev/null +++ b/src/test/java/domain/CarsTest.java @@ -0,0 +1,82 @@ +package domain; + + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CarsTest { + private static final String CAR_A_NAME = "carA"; + private static final String CAR_B_NAME = "carB"; + private static final String CAR_C_NAME = "carC"; + + @DisplayName("from 정적 팩터리 메서드를 통해 올바른 Cars 를 반환받을 수 있다.") + @Test + void should_ReturnCars_When_UsingFromMethod() { + //given + List carNames = List.of(CAR_A_NAME, CAR_B_NAME, CAR_C_NAME); + + //when + Cars cars = Cars.from(carNames); + + //then + assertThat(cars.getCars()) + .extracting("name") + .containsExactly(CAR_A_NAME, CAR_B_NAME, CAR_C_NAME); + } + + @DisplayName("moveAll 메서드는 모든 자동차를 움직일 수 있다.") + @Test + void should_MoveAllCars_When_UsingMoveAllMethod() { + //given + Car carA = Car.from(CAR_A_NAME); + Car carB = Car.from(CAR_B_NAME); + Car carC = Car.from(CAR_C_NAME); + Cars cars = new Cars(List.of(carA, carB, carC)); + + //when + RandomNumberGenerator randomNumberGenerator = new TestNumberGenerator(List.of(0, 9, 9)); + cars.moveAll(randomNumberGenerator); + + //then + assertThat(cars.getCars()) + .extracting("position") + .containsExactly(0, 1, 1); + } + + @DisplayName("findWinners 메서드는 우승한 자동차들을 반환한다.") + @Test + void should_ReturnWinners_When_UsingFindWinnersMethod() { + //given + Car carA = Car.of(CAR_A_NAME, 3); + Car carB = Car.of(CAR_B_NAME, 0); + Car carC = Car.of(CAR_C_NAME, 3); + Cars cars = new Cars(List.of(carA, carB, carC)); + + //when + Cars winners = cars.findWinners(); + + //then + assertThat(winners.getCars()) + .extracting("name") + .containsExactly(CAR_A_NAME, CAR_C_NAME); + } + + static class TestNumberGenerator extends RandomNumberGenerator { + + private final List testNumberList; + private int index = 0; + + public TestNumberGenerator(final List testNumberList) { + this.testNumberList = testNumberList; + } + + @Override + public int generate() { + return testNumberList.get(index++); + } + } +} diff --git a/src/test/java/domain/NameTest.java b/src/test/java/domain/NameTest.java new file mode 100644 index 0000000..2da00ff --- /dev/null +++ b/src/test/java/domain/NameTest.java @@ -0,0 +1,27 @@ +package domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class NameTest { + @DisplayName("자동차 이름의 길이가 1~5글자가 아니거나, null 이라면 예외를 던진다") + @ParameterizedTest + @CsvSource(value = {"''", "abcdef", "abcdefgh", "null"}, nullValues = "null") + void should_ThrowIllegalArgumentException_When_NameIsInvalid(String name) { + assertThatThrownBy(() -> new Name(name)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR]"); + } + + @DisplayName("자동차 이름의 길이가 1~5글자라면 예외를 던지지 않는다") + @ParameterizedTest + @CsvSource({"a", "abc", "abcde"}) + void should_DoesNotThrowException_When_NameIsValid(String name) { + assertThatCode(() -> new Name(name)) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/domain/PositionTest.java b/src/test/java/domain/PositionTest.java new file mode 100644 index 0000000..c202095 --- /dev/null +++ b/src/test/java/domain/PositionTest.java @@ -0,0 +1,23 @@ +package domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PositionTest { + @DisplayName("increase 메서드는 position 의 값을 1 증가시킨다.") + @ParameterizedTest + @CsvSource({"0,1", "1,2", "2,3"}) + void should_IncreaseByOne_When_UsingIncreaseMethod(int beforeValue, int afterValue) { + //given + Position position = new Position(beforeValue); + + //when + position.increase(); + + //then + assertThat(position.getPosition()).isEqualTo(afterValue); + } +}