diff --git a/docs/README.md b/docs/README.md index e69de29bb2d..26cba572bb3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -0,0 +1,42 @@ +# πŸ’ͺ ν”„λ‘œμ νŠΈ κ°œμš” + +μžλ™μ°¨ κ²½μ£Ό κ²Œμž„μ„ κ΅¬ν˜„ν•œλ‹€. +μžλ™μ°¨μ˜ 이름과 전진을 μ‹œλ„ν•  횟수λ₯Ό μž…λ ₯λ°›κ³ , μžλ™μ°¨λ₯Ό μ „μ§„μ‹œν‚¨ ν›„ 우승자λ₯Ό μ„ μ •ν•œλ‹€. + +# πŸ“ κ΅¬ν˜„ κΈ°λŠ₯ λͺ©λ‘ + +### μžλ™μ°¨ 이름을 μž…λ ₯ν•˜λŠ” κΈ°λŠ₯ + +- [x] `κ²½μ£Όν•  μžλ™μ°¨ 이름을 μž…λ ₯ν•˜μ„Έμš”.(이름은 μ‰Όν‘œ(,) κΈ°μ€€μœΌλ‘œ ꡬ뢄)`λ₯Ό 좜λ ₯ν•œλ‹€. +- [x] μžλ™μ°¨μ˜ 이름을 μž…λ ₯λ°›λŠ”λ‹€. + - [x] 빈 λ¬Έμžμ—΄μ΄ μ•„λ‹˜μ„ κ²€μ¦ν•œλ‹€. + - [x] μ˜¬λ°”λ₯Έ κ΅¬λΆ„μžλ‘œ κ΅¬λΆ„λ˜μ—ˆμŒμ„ κ²€μ¦ν•œλ‹€. + - [x] μ€‘λ³΅λ˜λŠ” 이름이 μ—†μŒμ„ κ²€μ¦ν•œλ‹€. + - [x] 5자 μ΄ν•˜μ˜ 이름인지 κ²€μ¦ν•œλ‹€. + +### μ‹œλ„ν•  횟수λ₯Ό μž…λ ₯ν•˜λŠ” κΈ°λŠ₯ + +- [x] `μ‹œλ„ν•  νšŒμˆ˜λŠ” λͺ‡νšŒμΈκ°€μš”?`λ₯Ό 좜λ ₯ν•œλ‹€. +- [x] μ‹œλ„ν•  횟수λ₯Ό μž…λ ₯ν•œλ‹€. + - [x] 빈 λ¬Έμžμ—΄μ΄ μ•„λ‹˜μ„ κ²€μ¦ν•œλ‹€. + - [x] 1 μ΄μƒμ˜ 숫자둜 이루어져 μžˆμŒμ„ κ²€μ¦ν•œλ‹€. + +### μžλ™μ°¨λ₯Ό μ „μ§„ν•˜λŠ” κΈ°λŠ₯ + +- [x] μž…λ ₯ν•œ 횟수만큼 μžλ™μ°¨ 전진을 μ‹œλ„ν•œλ‹€. +- [x] λ¬΄μž‘μœ„ 값을 μƒμ„±ν•˜κ³  μ „μ§„ν•˜λŠ” 쑰건을 κ²€μ‚¬ν•œλ‹€. +- [x] 쑰건에 λΆ€ν•©ν•œλ‹€λ©΄ μžλ™μ°¨λ₯Ό μ „μ§„μ‹œν‚¨λ‹€. + +### μžλ™μ°¨μ˜ μƒνƒœλ₯Ό 좜λ ₯ν•˜λŠ” κΈ°λŠ₯ + +- [x] λͺ¨λ“  μžλ™μ°¨μ˜ 이름과 μ΄λ™ν•œ 갯수λ₯Ό 뷰에 μ „λ‹¬ν•œλ‹€. +- [x] λ·°μ—μ„œ μžλ™μ°¨ 이름을 좜λ ₯ν•˜κ³  μ΄λ™ν•œ 갯수λ₯Ό `-`둜 좜λ ₯ν•œλ‹€. + +### 우승자λ₯Ό 좜λ ₯ν•˜λŠ” κΈ°λŠ₯ + +- [x] κ°€μž₯ 많이 μ΄λ™ν•œ μžλ™μ°¨λ₯Ό μ„ μ •ν•œλ‹€. +- [x] `μ΅œμ’… 우승자 : pobi`와 같이 우승자λ₯Ό 좜λ ₯ν•œλ‹€. + +# πŸ›  μ‹œμŠ€ν…œ 흐름도 + +![img.png](흐름도.png) diff --git "a/docs/\355\235\220\353\246\204\353\217\204.png" "b/docs/\355\235\220\353\246\204\353\217\204.png" new file mode 100644 index 00000000000..23fea6a34e7 Binary files /dev/null and "b/docs/\355\235\220\353\246\204\353\217\204.png" differ diff --git a/src/main/java/racingcar/Application.java b/src/main/java/racingcar/Application.java index a17a52e7242..50cb6d90e69 100644 --- a/src/main/java/racingcar/Application.java +++ b/src/main/java/racingcar/Application.java @@ -1,7 +1,17 @@ package racingcar; +import camp.nextstep.edu.missionutils.Console; +import racingcar.controller.GameManager; +import racingcar.view.InputView; +import racingcar.view.OutputView; + public class Application { public static void main(String[] args) { - // TODO: ν”„λ‘œκ·Έλž¨ κ΅¬ν˜„ + GameManager gameManager = new GameManager( + new InputView(), + new OutputView() + ); + gameManager.run(); + Console.close(); } } diff --git a/src/main/java/racingcar/controller/GameManager.java b/src/main/java/racingcar/controller/GameManager.java new file mode 100644 index 00000000000..be948fc271d --- /dev/null +++ b/src/main/java/racingcar/controller/GameManager.java @@ -0,0 +1,36 @@ +package racingcar.controller; + +import java.util.List; +import racingcar.domain.Cars; +import racingcar.view.InputView; +import racingcar.view.OutputView; + +public class GameManager { + private final InputView inputView; + private final OutputView outputView; + + public GameManager(InputView inputView, OutputView outputView) { + this.inputView = inputView; + this.outputView = outputView; + } + + public void run() { + Cars cars = new Cars(inputView.readCarNames()); + int count = inputView.readTryCount(); + + for (int i = 0; i < count; i++) { + play(cars); + } + complete(cars); + } + + private void play(Cars cars) { + cars.moveCars(); + outputView.printResult(cars); + } + + private void complete(Cars cars) { + List winners = cars.selectWinners(); + outputView.printWinners(winners); + } +} diff --git a/src/main/java/racingcar/domain/Car.java b/src/main/java/racingcar/domain/Car.java new file mode 100644 index 00000000000..8ab801f4ce0 --- /dev/null +++ b/src/main/java/racingcar/domain/Car.java @@ -0,0 +1,39 @@ +package racingcar.domain; + +import racingcar.global.util.RandomNumberGenerator; + +public class Car { + private static final int THRESHOLD = 4; + private static final int START = 0; + private static final int END = 9; + public static final int INTERVAL = 1; + private Name name; + private int moved; + + public Car(Name name) { + this.name = name; + this.moved = 0; + } + + public void move() { + if (canMove()) { + moved += INTERVAL; + } + } + + private boolean canMove() { + int number = RandomNumberGenerator.generate(START, END); + if (number >= THRESHOLD) { + return true; + } + return false; + } + + public int moved() { + return moved; + } + + public String name() { + return name.getValue(); + } +} diff --git a/src/main/java/racingcar/domain/Cars.java b/src/main/java/racingcar/domain/Cars.java new file mode 100644 index 00000000000..cc59b39c03e --- /dev/null +++ b/src/main/java/racingcar/domain/Cars.java @@ -0,0 +1,47 @@ +package racingcar.domain; + +import java.util.List; + +public class Cars { + private final List cars; + + public Cars(List cars) { + this.cars = generateToCars(cars); + } + + private List generateToCars(List cars) { + return cars.stream() + .map(name -> new Car(new Name(name))) + .toList(); + } + + public void moveCars() { + for (Car car : cars) { + car.move(); + } + } + + public List selectWinners() { + int maxMoved = getMaxMoved(); + + return cars.stream() + .filter(car -> car.moved() == maxMoved) + .map(Car::name) + .toList(); + } + + private int getMaxMoved() { + return cars.stream() + .mapToInt(car -> car.moved()) + .max() + .orElseThrow(IllegalStateException::new); + } + + public int size() { + return cars.size(); + } + + public Car get(int i) { + return cars.get(i); + } +} diff --git a/src/main/java/racingcar/domain/Name.java b/src/main/java/racingcar/domain/Name.java new file mode 100644 index 00000000000..0a8fe83adf2 --- /dev/null +++ b/src/main/java/racingcar/domain/Name.java @@ -0,0 +1,31 @@ +package racingcar.domain; + +import racingcar.global.exception.CustomException; +import racingcar.global.exception.ErrorMessage; + +public class Name { + public static final int MIN = 1; + public static final int MAX = 5; + private final String name; + + public Name(String name) { + Validator.validateRange(name.length(), MIN, MAX); + this.name = name; + } + + public String getValue() { + return name; + } + + public static class Validator { + private static void validateRange(int length, int start, int end) { + if (isInvalidRange(length, start, end)) { + throw CustomException.from(ErrorMessage.INVALID_LENGTH_ERROR); + } + } + + private static boolean isInvalidRange(int number, int start, int end) { + return number < start || number > end; + } + } +} diff --git a/src/main/java/racingcar/global/exception/CustomException.java b/src/main/java/racingcar/global/exception/CustomException.java new file mode 100644 index 00000000000..1c423097d63 --- /dev/null +++ b/src/main/java/racingcar/global/exception/CustomException.java @@ -0,0 +1,13 @@ +package racingcar.global.exception; + +public class CustomException extends IllegalArgumentException { + private static final String PREFIX = "[ERROR] "; + + private CustomException(ErrorMessage errorMessage) { + super(PREFIX + errorMessage.getMessage()); + } + + public static CustomException from(ErrorMessage errorMessage) { + return new CustomException(errorMessage); + } +} diff --git a/src/main/java/racingcar/global/exception/ErrorMessage.java b/src/main/java/racingcar/global/exception/ErrorMessage.java new file mode 100644 index 00000000000..a1e998169f8 --- /dev/null +++ b/src/main/java/racingcar/global/exception/ErrorMessage.java @@ -0,0 +1,18 @@ +package racingcar.global.exception; + +public enum ErrorMessage { + BLANK_INPUT_ERROR("빈 λ¬Έμžμ—΄μ΄ μž…λ ₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€."), + INVALID_LENGTH_ERROR("잘λͺ»λœ 길이의 λ¬Έμžμ—΄μ„ μž…λ ₯ν•˜μ˜€μŠ΅λ‹ˆλ‹€. "), + DUPLICATED_CAR_ERROR("μžλ™μ°¨μ˜ 이름이 μ€‘λ³΅λ˜μ—ˆμŠ΅λ‹ˆλ‹€."), + INVALID_TRY_COUNT_ERROR("잘λͺ»λœ μ‹œλ„ 횟수의 μž…λ ₯μž…λ‹ˆλ‹€."); + + private final String message; + + ErrorMessage(String message) { + this.message = message; + } + + public String getMessage() { + return this.message; + } +} diff --git a/src/main/java/racingcar/global/util/RandomNumberGenerator.java b/src/main/java/racingcar/global/util/RandomNumberGenerator.java new file mode 100644 index 00000000000..9f27d67da44 --- /dev/null +++ b/src/main/java/racingcar/global/util/RandomNumberGenerator.java @@ -0,0 +1,9 @@ +package racingcar.global.util; + +import camp.nextstep.edu.missionutils.Randoms; + +public class RandomNumberGenerator { + public static int generate(int start, int end) { + return Randoms.pickNumberInRange(start, end); + } +} diff --git a/src/main/java/racingcar/view/InputView.java b/src/main/java/racingcar/view/InputView.java new file mode 100644 index 00000000000..44a9cee6336 --- /dev/null +++ b/src/main/java/racingcar/view/InputView.java @@ -0,0 +1,67 @@ +package racingcar.view; + +import java.util.Arrays; +import java.util.List; +import racingcar.global.exception.CustomException; +import racingcar.global.exception.ErrorMessage; +import racingcar.view.console.ConsoleReader; +import racingcar.view.console.ConsoleWriter; + +public class InputView { + private static final String CAR_NAMES_NOTICE = "κ²½μ£Όν•  μžλ™μ°¨ 이름을 μž…λ ₯ν•˜μ„Έμš”.(이름은 μ‰Όν‘œ(,) κΈ°μ€€μœΌλ‘œ ꡬ뢄)`λ₯Ό 좜λ ₯ν•œλ‹€."; + public static final String CAR_NAMES_SEPARATOR = ","; + public static final String TRY_COUNT_NOTICE = "μ‹œλ„ν•  νšŒμˆ˜λŠ” λͺ‡νšŒμΈκ°€μš”?"; + + public List readCarNames() { + ConsoleWriter.printlnMessage(CAR_NAMES_NOTICE); + return Validator.validateCarNames(ConsoleReader.enterMessage()); + } + + public int readTryCount() { + ConsoleWriter.printlnMessage(TRY_COUNT_NOTICE); + return Validator.validateNumber(ConsoleReader.enterMessage()); + } + + private static class Validator { + private static List validateCarNames(String message) { + List cars = parseStringToList(message, CAR_NAMES_SEPARATOR); + validateDuplicated(cars); + return cars; + } + + private static void validateDuplicated(List items) { + if (hasDuplicated(items)) { + throw CustomException.from(ErrorMessage.DUPLICATED_CAR_ERROR); + } + } + + private static boolean hasDuplicated(List items) { + return items.size() != calculateUniqueCount(items); + } + + private static int calculateUniqueCount(List items) { + return (int) items.stream() + .distinct() + .count(); + } + + private static List parseStringToList(String message, String separator) { + return Arrays.stream(split(message, separator)).toList(); + } + + private static String[] split(String message, String separator) { + return message.split(separator, -1); + } + + private static int validateNumber(String message) { + if (isNotNumber(message)) { + throw CustomException.from(ErrorMessage.INVALID_TRY_COUNT_ERROR); + } + return Integer.parseInt(message); + } + + private static boolean isNotNumber(String str) { + return !str.matches("^[1-9]+$"); + } + } +} diff --git a/src/main/java/racingcar/view/OutputView.java b/src/main/java/racingcar/view/OutputView.java new file mode 100644 index 00000000000..4c89bda6e3a --- /dev/null +++ b/src/main/java/racingcar/view/OutputView.java @@ -0,0 +1,42 @@ +package racingcar.view; + +import java.util.List; +import racingcar.domain.Car; +import racingcar.domain.Cars; +import racingcar.view.console.ConsoleWriter; + +public class OutputView { + private static final String RESULT_NOTICE = "μ‹€ν–‰ κ²°κ³Ό"; + private static final String WINNER_NOTICE = "μ΅œμ’… 우승자 : %s"; + private static final String WINNER_SEPARATOR = ", "; + + public void printResult(Cars cars) { + ConsoleWriter.printlnMessage(RESULT_NOTICE); + printCarsStatus(cars); + ConsoleWriter.println(); + } + + private void printCarsStatus(Cars cars) { + for (int i = 0; i < cars.size(); i++) { + Car car = cars.get(i); + printCarStatus(car, i); + } + } + + private void printCarStatus(Car car, int i) { + String name = car.name(); + int moved = car.moved(); + ConsoleWriter.printlnMessage(name + " : " + "-".repeat(moved)); + } + + public void printWinners(List winners) { + ConsoleWriter.printlnFormat( + WINNER_NOTICE, + generateWinnerResult(winners) + ); + } + + private String generateWinnerResult(List winners) { + return String.join(WINNER_SEPARATOR, winners); + } +} diff --git a/src/main/java/racingcar/view/console/ConsoleReader.java b/src/main/java/racingcar/view/console/ConsoleReader.java new file mode 100644 index 00000000000..70e24343e10 --- /dev/null +++ b/src/main/java/racingcar/view/console/ConsoleReader.java @@ -0,0 +1,24 @@ +package racingcar.view.console; + +import camp.nextstep.edu.missionutils.Console; +import racingcar.global.exception.CustomException; +import racingcar.global.exception.ErrorMessage; + +public final class ConsoleReader { + public static String enterMessage() { + return Validator.validate(Console.readLine()); + } + + private static class Validator { + public static String validate(String message) { + validateBlankInput(message); + return message; + } + + private static void validateBlankInput(String message) { + if (message.isBlank()) { + throw CustomException.from(ErrorMessage.BLANK_INPUT_ERROR); + } + } + } +} diff --git a/src/main/java/racingcar/view/console/ConsoleWriter.java b/src/main/java/racingcar/view/console/ConsoleWriter.java new file mode 100644 index 00000000000..06636a36d92 --- /dev/null +++ b/src/main/java/racingcar/view/console/ConsoleWriter.java @@ -0,0 +1,15 @@ +package racingcar.view.console; + +public final class ConsoleWriter { + public static void printlnMessage(String message) { + System.out.println(message); + } + + public static void printlnFormat(String message, Object... args) { + printlnMessage(String.format(message, args)); + } + + public static void println() { + System.out.println(); + } +} diff --git a/src/test/java/racingcar/domain/CarTest.java b/src/test/java/racingcar/domain/CarTest.java new file mode 100644 index 00000000000..8542b551770 --- /dev/null +++ b/src/test/java/racingcar/domain/CarTest.java @@ -0,0 +1,55 @@ +package racingcar.domain; + +import static org.mockito.Mockito.mockStatic; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.mockito.MockedStatic; +import racingcar.global.util.RandomNumberGenerator; + +class CarTest { + + private static MockedStatic generator; + + @BeforeAll + public static void beforeALl() { + generator = mockStatic(RandomNumberGenerator.class); + } + + @AfterAll + public static void afterAll() { + generator.close(); + } + + @Test + @DisplayName("이동 쑰건을 λ§Œμ‘±ν•˜μ—¬ μžλ™μ°¨κ°€ μ΄λ™ν•œλ‹€.") + void moveSuccessTest() { + // given + Car car = new Car(new Name("μžλ™μ°¨")); + BDDMockito.given(RandomNumberGenerator.generate(0, 9)).willReturn(5); + + // when + car.move(); + + // then + Assertions.assertThat(car.moved()).isEqualTo(1); + } + + @Test + @DisplayName("이동 쑰건을 λ§Œμ‘±ν•˜μ§€ λͺ»ν•˜μ—¬ μžλ™μ°¨κ°€ μ΄λ™ν•˜μ§€ μ•ŠλŠ”λ‹€.") + void moveFailTest() { + // given + Car car = new Car(new Name("μžλ™μ°¨")); + BDDMockito.given(RandomNumberGenerator.generate(0, 9)).willReturn(1); + + // when + car.move(); + + // then + Assertions.assertThat(car.moved()).isEqualTo(0); + } +} diff --git a/src/test/java/racingcar/domain/CarsTest.java b/src/test/java/racingcar/domain/CarsTest.java new file mode 100644 index 00000000000..7ccc08ab5b4 --- /dev/null +++ b/src/test/java/racingcar/domain/CarsTest.java @@ -0,0 +1,90 @@ +package racingcar.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mockStatic; + +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.mockito.MockedStatic; +import racingcar.global.exception.ErrorMessage; +import racingcar.global.util.RandomNumberGenerator; + +class CarsTest { + private static MockedStatic generator; + + @BeforeAll + public static void beforeALl() { + generator = mockStatic(RandomNumberGenerator.class); + } + + @AfterAll + public static void afterAll() { + generator.close(); + } + + @Test + @DisplayName("μžλ™μ°¨ μ΄λ¦„μ˜ μž…λ ₯으둜 Cars 객체λ₯Ό μƒμ„±ν•œλ‹€.") + void generateToCarsTest() { + // given + List names = Arrays.asList("car1", "car2", "car3"); + Cars cars = new Cars(names); + + // when & then + assertThat(cars.size()).isEqualTo(3); + } + + @Test + @DisplayName("5자 초과의 이름 μž…λ ₯으둜 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + void TooLongCarNameError() { + // given + List names = Arrays.asList("carrrrrr", "car"); + + assertThatThrownBy(() -> new Cars(names)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ErrorMessage.INVALID_LENGTH_ERROR.getMessage()); + } + + @Test + @DisplayName("단일 우승자λ₯Ό μ„±κ³΅μ μœΌλ‘œ κ°€λ €λ‚Έλ‹€.") + void selectSingleWinnersTest() { + // given + List names = Arrays.asList("car1", "car2", "car3"); + BDDMockito.given(RandomNumberGenerator.generate(anyInt(), anyInt())) + .willReturn(3, 6, 1); + Cars cars = new Cars(names); + + // when + cars.moveCars(); + List winners = cars.selectWinners(); + + // then + assertThat(winners.size()).isEqualTo(1); + assertThat(winners.get(0)).isEqualTo("car2"); + } + + @Test + @DisplayName("볡수의 우승자λ₯Ό μ„±κ³΅μ μœΌλ‘œ κ°€λ €λ‚Έλ‹€.") + void selectCoupleWinnersTest() { + // given + List names = Arrays.asList("car1", "car2", "car3"); + BDDMockito.given(RandomNumberGenerator.generate(anyInt(), anyInt())) + .willReturn(4, 6, 1); + Cars cars = new Cars(names); + + // when + cars.moveCars(); + List winners = cars.selectWinners(); + + // then + assertThat(winners.size()).isEqualTo(2); + assertThat(winners.get(0)).isEqualTo("car1"); + assertThat(winners.get(1)).isEqualTo("car2"); + } +}