diff --git a/README.md b/README.md index 1969313..a97175b 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,41 @@ 자동차 경주 미션 저장소 +## 패키지 구조 +### [Model] +#### Car +- 'Name'과 'Position'을 담은 객체 +#### Cars +- 게임에 참여하는 'Car'들을 묶은 객체 +#### Name +- 'Car'의 이름을 담은 객체 +#### Position +- 'Car'의 위치를 담은 객체 +#### RandomNumber +- 전진 조건이 되는 수를 생성하는 객체 +#### TryCount +- 게임 진행 횟수를 담은 객체 + +### [View] +#### InputView +- 사용자의 입력을 받음 +#### OutputView +- 안내 문구 및 결과 출력 +#### ViewMessage(enum) +- 입출력 문구 모음 + +### [Controller] +#### RacingController +- 게임 진행 + +### [Util] +#### ErrorMessage(enum) +- 에러 문구 모음 +#### Validation +- Model과 View 검증 + +## 필요 기능 +1. 자동차 정보 입력 +2. 시도 횟수 입력 +3. 실행 결과 출력 +4. 우승자 출력 \ No newline at end of file diff --git a/src/main/java/RacingMain.java b/src/main/java/RacingMain.java index 4394287..dad37bb 100644 --- a/src/main/java/RacingMain.java +++ b/src/main/java/RacingMain.java @@ -1,7 +1,12 @@ +import controller.RacingController; + +import java.io.IOException; + public class RacingMain { - public static void main(String[] args) { + public static void main(String[] args) throws IOException { // TODO: MVC 패턴을 기반으로 자동차 경주 미션 구현해보기 - System.out.println("Hello, World!"); + final var racingController = new RacingController(); + racingController.run(); } } diff --git a/src/main/java/controller/RacingController.java b/src/main/java/controller/RacingController.java new file mode 100644 index 0000000..50adb05 --- /dev/null +++ b/src/main/java/controller/RacingController.java @@ -0,0 +1,44 @@ +package controller; + +import domain.Cars; +import domain.TryCount; +import view.InputView; +import view.OutputView; + +import java.io.IOException; +import java.util.List; + +public class RacingController { + public void run() throws IOException { + Cars cars = new Cars(createCars()); + TryCount tryCount = new TryCount(setTryCount()); + racingResult(tryCount, cars); + findWinners(cars); + } + + // 자동차 정보 입력 + private List createCars() throws IOException { + return InputView.getCarName(); + } + + // 시도 횟수 입력 + private int setTryCount() throws IOException { + return InputView.getTryCount(); + } + + // 시행 결과 출력 + private void racingResult(TryCount tryCount, Cars cars) { + OutputView.printRacingResult(); + + while(tryCount.isPlayable()) { + tryCount.decreaseTryCount(); + cars.moveCars(); + OutputView.printRacingStatus(cars.getCars()); + } + } + + // 우승자 출력 + private void findWinners(Cars cars) { + OutputView.printRacingWinner(cars.findWinnerName()); + } +} diff --git a/src/main/java/domain/Car.java b/src/main/java/domain/Car.java new file mode 100644 index 0000000..99aae11 --- /dev/null +++ b/src/main/java/domain/Car.java @@ -0,0 +1,33 @@ +package domain; + +import util.Validation; + +public class Car { + // 자동차 + private static final int MINIMUM_MOVE_POWER = 4; + private final Name name; + private final Position position; + + public Car(String name) { + Validation.validationNameSize(name); + this.name = new Name(name); + this.position = new Position(); + } + + // 자동차 이름 반환 + public String getCarName() { + return name.name(); + } + + // 자동차 위치 반환 + public int getCarPosition() { + return position.getPosition(); + } + + // 자동차 전진 + public void moveCar(int number) { + if (number >= MINIMUM_MOVE_POWER) { + position.increasePosition(); + } + } +} diff --git a/src/main/java/domain/Cars.java b/src/main/java/domain/Cars.java new file mode 100644 index 0000000..8e91dfc --- /dev/null +++ b/src/main/java/domain/Cars.java @@ -0,0 +1,64 @@ +package domain; + +import util.Validation; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class Cars { + // 자동차 경주에 참여하는 자동차들 + private static final int DEFAULT_POSITION = 0; + private final List cars; + public Cars(List carNames) { + Validation.validationCarNumber(carNames); + Validation.validationDuplicatedName(carNames); + + cars = new ArrayList<>(); + generateCars(carNames, cars); + } + + // 자동차 생성 + private void generateCars(List carNames, List cars) { + for(String name: carNames) { + Car car = new Car(name); + cars.add(car); + } + } + + // 자동차 리스트 반환 + public List getCars() { + return cars; + } + + // 자동차 전진 + public void moveCars() { + for (Car c: cars) { + int number = RandomNumber.getRandomNumber(); + c.moveCar(number); + } + } + + // 우승자 이름 반환 + public List findWinnerName() { + return findWinner().stream() + .map(Car::getCarName) + .collect(Collectors.toList()); + } + + // 우승자 반환 + private List findWinner() { + int winnerPosition = getWinnerPosition(); + return cars.stream() + .filter(car -> car.getCarPosition() == winnerPosition) + .collect(Collectors.toList()); + } + + // 우승자 위치 반환 + private int getWinnerPosition() { + return cars.stream() + .mapToInt(Car::getCarPosition) + .max() + .orElse(DEFAULT_POSITION); + } +} diff --git a/src/main/java/domain/Name.java b/src/main/java/domain/Name.java new file mode 100644 index 0000000..5298811 --- /dev/null +++ b/src/main/java/domain/Name.java @@ -0,0 +1,7 @@ +package domain; + +/** + * @param name 자동차 이름 + */ +public record Name(String name) { +} diff --git a/src/main/java/domain/Position.java b/src/main/java/domain/Position.java new file mode 100644 index 0000000..e235874 --- /dev/null +++ b/src/main/java/domain/Position.java @@ -0,0 +1,21 @@ +package domain; + +public class Position { + // 자동차 위치 + private static final int INITIAL_POSITION = 0; + private int position; + + public Position() { + position = INITIAL_POSITION; + } + + // 위치 반환 + public int getPosition() { + return position; + } + + // 위치 증가(전진) + public void increasePosition() { + position++; + } +} diff --git a/src/main/java/domain/RandomNumber.java b/src/main/java/domain/RandomNumber.java new file mode 100644 index 0000000..e544aa6 --- /dev/null +++ b/src/main/java/domain/RandomNumber.java @@ -0,0 +1,13 @@ +package domain; + +import java.util.Random; + +public class RandomNumber { + private static final int NUMBER_UPPER_BOUND = 10; + private static final Random random = new Random(); + + // 1~10 난수 반환 + public static int getRandomNumber() { + return random.nextInt(NUMBER_UPPER_BOUND); + } +} diff --git a/src/main/java/domain/TryCount.java b/src/main/java/domain/TryCount.java new file mode 100644 index 0000000..4fa03f9 --- /dev/null +++ b/src/main/java/domain/TryCount.java @@ -0,0 +1,23 @@ +package domain; + +import util.Validation; + +public class TryCount { + private static final int PLAYABLE_LOWER_BOUND = 1; + private int tryCount; + + public TryCount(int tryCount) { + Validation.validationTryCount(tryCount); + this.tryCount = tryCount; + } + + // 시도 횟수 감소 + public void decreaseTryCount() { + tryCount--; + } + + // 시도 가능 여부 반환 + public boolean isPlayable() { + return PLAYABLE_LOWER_BOUND <= tryCount; + } +} diff --git a/src/main/java/util/ErrorMessage.java b/src/main/java/util/ErrorMessage.java new file mode 100644 index 0000000..28b6b4e --- /dev/null +++ b/src/main/java/util/ErrorMessage.java @@ -0,0 +1,20 @@ +package util; + +public enum ErrorMessage { + NAME_SIZE_ERROR("이름은 1글자 이상 5글자 이하로 작성해주세요."), + NAME_DUPLICATE_ERROR("이름은 중복될 수 없습니다."), + CAR_NUMBER_ERROR("자동차는 1대 이상이어야 합니다."), + TRY_TYPE_ERROR("입력값은 정수여야 합니다."), + TRY_RANGE_ERROR("시도 횟수는 양의 정수여야 합니다."); + + private final String message; + private static final String START_ERROR = "[ERROR] "; + + ErrorMessage(String message) { + this.message = message; + } + + public String getMessage() { + return START_ERROR + message; + } +} diff --git a/src/main/java/util/Validation.java b/src/main/java/util/Validation.java new file mode 100644 index 0000000..86ff87c --- /dev/null +++ b/src/main/java/util/Validation.java @@ -0,0 +1,50 @@ +package util; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static util.ErrorMessage.*; + +public class Validation { + private static final int COUNT_LOWER_BOUND = 0; + private static final int NAME_MAX_SIZE = 5; + + // 이름 길이 유효 여부 검사 + public static void validationNameSize(String name) { + if (name == null || name.isEmpty() || name.length() > NAME_MAX_SIZE) { + throw new IllegalArgumentException(NAME_SIZE_ERROR.getMessage()); + } + } + + // 자동차 존재 여부 검사 + public static void validationCarNumber(List names) { + if (names.isEmpty()) { + throw new IllegalArgumentException(CAR_NUMBER_ERROR.getMessage()); + } + } + + // 자동차 이름 중복 검사 + public static void validationDuplicatedName(List names) { + Set deduplicate = new HashSet<>(names); + if (deduplicate.size() != names.size()) { + throw new IllegalArgumentException(NAME_DUPLICATE_ERROR.getMessage()); + } + } + + // 정수 입력 검사 + public static Integer parseInteger(String input) { + try { + return Integer.parseInt(input); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(TRY_TYPE_ERROR.getMessage()); + } + } + + // 시도 횟수가 양의 정수인지 검사 + public static void validationTryCount(int tryCount) { + if (tryCount <= COUNT_LOWER_BOUND) { + throw new IllegalArgumentException(TRY_RANGE_ERROR.getMessage()); + } + } +} diff --git a/src/main/java/view/InputView.java b/src/main/java/view/InputView.java new file mode 100644 index 0000000..14e6535 --- /dev/null +++ b/src/main/java/view/InputView.java @@ -0,0 +1,36 @@ +package view; + +import util.Validation; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static view.ViewMessage.*; + +public class InputView { + + private static final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in)); + + // 자동차 이름 입력 + public static List getCarName() throws IOException { + System.out.println(INPUT_CAR_NAME_MESSAGE.getMessage()); + return splitNames(bufferedReader.readLine()); + } + + // 분리된 자동차 이름 반환 + private static List splitNames(String names) { + return Arrays.stream(names.split(SEPARATOR_VALUE.getMessage())) + .map(String::trim) + .collect(Collectors.toList()); + } + + // 시도 횟수 입력 + public static int getTryCount() throws IOException { + System.out.println(TRY_COUNT_MESSAGE.getMessage()); + return Validation.parseInteger(bufferedReader.readLine()); + } +} diff --git a/src/main/java/view/OutputView.java b/src/main/java/view/OutputView.java new file mode 100644 index 0000000..798c2cf --- /dev/null +++ b/src/main/java/view/OutputView.java @@ -0,0 +1,39 @@ +package view; + +import domain.Car; + +import java.util.List; + +import static view.ViewMessage.*; + +public class OutputView { + + // 실행 결과 메시지 출력 + public static void printRacingResult() { + System.out.println(TRY_RESULT_MESSAGE.getMessage()); + } + + // 실행 결과 출력 + public static void printRacingStatus(List cars) { + for (Car c: cars) { + String position = getCurrentPosition(c.getCarPosition()); + System.out.printf(STATUS_MESSAGE_FORMAT.getMessage(), c.getCarName(), position); + } + System.out.println(); + } + + // 심볼로 표현된 위치 반환 + private static String getCurrentPosition(int position) { + return POSITION_SYMBOL.getMessage().repeat(position); + } + + // 우승자 출력 + public static void printRacingWinner(List winners) { + System.out.printf(WINNER_MESSAGE_FORMAT.getMessage(), getCombinedWinner(winners)); + } + + // 합쳐진 우승자 이름 반환 + private static String getCombinedWinner(List winners) { + return String.join(DELIMITER_VALUE.getMessage(), winners); + } +} diff --git a/src/main/java/view/ViewMessage.java b/src/main/java/view/ViewMessage.java new file mode 100644 index 0000000..58550b1 --- /dev/null +++ b/src/main/java/view/ViewMessage.java @@ -0,0 +1,22 @@ +package view; + +public enum ViewMessage { + INPUT_CAR_NAME_MESSAGE("경주할 자동차 이름을 입력하세요(이름은 쉼표(,)를 기준으로 구분)."), + TRY_COUNT_MESSAGE("시도할 횟수는 몇회인가요?"), + SEPARATOR_VALUE(","), + TRY_RESULT_MESSAGE("실행 결과"), + STATUS_MESSAGE_FORMAT("%s : %s\n"), + POSITION_SYMBOL("-"), + WINNER_MESSAGE_FORMAT("%s가 최종 우승했습니다."), + DELIMITER_VALUE(", "); + + private final String message; + + ViewMessage(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } +} diff --git a/src/test/java/domain/CarTest.java b/src/test/java/domain/CarTest.java new file mode 100644 index 0000000..2e5d4d3 --- /dev/null +++ b/src/test/java/domain/CarTest.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.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; + +class CarTest { + @DisplayName("자동차 이름이 1~5글자면 유효") + @ParameterizedTest + @CsvSource(value = {"a", "ab", "abc"}) + void should_DoesNotThrowException_When_ValidNameSIze(String name) { + assertThatCode(() -> new Car(name)) + .doesNotThrowAnyException(); + } + + @DisplayName("자동차 이름이 1~5글자가 아니라면 예외를 던진다") + @ParameterizedTest + @CsvSource(value = {"''", "qwertyuio", "abcdef", "null"}, nullValues = "null") + void should_ThrowException_When_InvalidNameSize(String name) { + assertThatThrownBy(() -> new Car(name)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR]"); + } +} diff --git a/src/test/java/domain/CarsTest.java b/src/test/java/domain/CarsTest.java new file mode 100644 index 0000000..10a3a38 --- /dev/null +++ b/src/test/java/domain/CarsTest.java @@ -0,0 +1,52 @@ +package domain; + +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 java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +class CarsTest { + @DisplayName("자동차가 1대 이상이고 이름 중복이 없으면 유효") + @ParameterizedTest + @CsvSource(value = {"1, 50, 100"}) + void should_DoesNotThrowException_When_ValidCar(int size) { + List carNames = new ArrayList<>(); + for (int i=0; i new Cars(carNames)) + .doesNotThrowAnyException(); + } + + @DisplayName("자동차가 0대면 예외를 던진다") + @Test + void should_ThrowException_When_InvalidCarNumber() { + List carNames = Collections.emptyList(); + + assertThatThrownBy(() -> new Cars(carNames)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR]"); + } + + @DisplayName("자동차 이름이 중복되면 예외를 던진다") + @ParameterizedTest + @CsvSource(value = {"2", "5", "10"}) + void should_ThrowException_When_DuplicatedName(int size) { + List carNames = new ArrayList<>(); + for(int i=0; i new Cars(carNames)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR]"); + } +} diff --git a/src/test/java/domain/PositionTest.java b/src/test/java/domain/PositionTest.java new file mode 100644 index 0000000..d4922c4 --- /dev/null +++ b/src/test/java/domain/PositionTest.java @@ -0,0 +1,19 @@ +package domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class PositionTest { + @DisplayName("increasePosition은 position을 1 증가시킨다") + @ParameterizedTest + @CsvSource(value = {"1"}) + void should_IncreasePosition_When_UsingIncreasePosition(int movedPosition) { + Position position = new Position(); + position.increasePosition(); + + assertEquals(position.getPosition(), movedPosition); + } +} diff --git a/src/test/java/domain/TryCountTest.java b/src/test/java/domain/TryCountTest.java new file mode 100644 index 0000000..6dabab1 --- /dev/null +++ b/src/test/java/domain/TryCountTest.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.AssertionsForClassTypes.assertThatCode; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +public class TryCountTest { + @DisplayName("TryCount가 양의 정수면 유효") + @ParameterizedTest + @CsvSource(value = {"1", "5", "100"}) + void should_DoesNotThrowException_When_PositiveTryCount(int number) { + assertThatCode(() -> new TryCount(number)) + .doesNotThrowAnyException(); + } + + @DisplayName("TryCount가 0 이하면 예외를 던진다") + @ParameterizedTest + @CsvSource(value = {"0", "-1", "-100"}) + void should_ThrowException_When_InvalidTryCount(int number) { + assertThatThrownBy(()-> new TryCount(number)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR]"); + } +} diff --git a/src/test/java/util/ValidationTest.java b/src/test/java/util/ValidationTest.java new file mode 100644 index 0000000..51391d6 --- /dev/null +++ b/src/test/java/util/ValidationTest.java @@ -0,0 +1,27 @@ +package util; + +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; + +public class ValidationTest { + @DisplayName("정수가 들어오면 유효") + @ParameterizedTest + @CsvSource(value = {"1", "5", "100"}) + void should_DoesNotThrowException_When_InputIsInteger(String input) { + assertThatCode(() -> Validation.parseInteger(input)) + .doesNotThrowAnyException(); + } + + @DisplayName("정수가 아닌 값이 들어오면 예외를 던진다") + @ParameterizedTest + @CsvSource(value = {"a", "5.5", "''", "***"}) + void should_ThrowException_When_InputIsNotInteger(String input) { + assertThatThrownBy(() -> Validation.parseInteger(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR]"); + } +}