diff --git a/README.md b/README.md index c550c4c2a09..3eb2ef5a45e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,69 @@ -# 자동차 경주 게임 -## 진행 방법 -* 자동차 경주 게임 요구사항을 파악한다. -* 요구사항에 대한 구현을 완료한 후 자신의 github 아이디에 해당하는 브랜치에 Pull Request(이하 PR)를 통해 코드 리뷰 요청을 한다. -* 코드 리뷰 피드백에 대한 개선 작업을 하고 다시 PUSH한다. -* 모든 피드백을 완료하면 다음 단계를 도전하고 앞의 과정을 반복한다. - -## 온라인 코드 리뷰 과정 -* [텍스트와 이미지로 살펴보는 온라인 코드 리뷰 과정](https://github.com/next-step/nextstep-docs/tree/master/codereview) \ No newline at end of file +# 3단계 자동차 경주 + +--- +## 기능 요구사항 +- [x] 초간단 자동차 경주 게임을 구현한다. +- [x] 주어진 횟수 동안 n대의 자동차는 전진 또는 멈출 수 있다. +- [x] 사용자는 몇 대의 자동차로 몇 번의 이동을 할 것인지를 입력할 수 있어야 한다. +- [x] 전진하는 조건은 0에서 9 사이에서 random 값을 구한 후 random 값이 4이상일 경우이다. +- [x] 자동차의 상태를 화면에 출력한다. 어느 시점에 출력할 것인지에 대한 제약은 없다. + +## 프로그래밍 요구사항 +- [x] 모든 로직에 단위 테스트를 구현한다. 단, UI(System.out, System.in) 로직은 제외 + - 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 구분한다. + - UI 로직을 race.view.InputView, ResultView와 같은 클래스를 추가해 분리한다. +- [x] 자바 코드 컨벤션을 지키면서 프로그래밍한다. + - 이 과정의 Code Style은 [intellij idea Code Style. Java](https://www.jetbrains.com/help/idea/code-style-java.html)을 따른다. + - intellij idea Code Style. Java을 따르려면 code formatting 단축키(Windows : Ctrl + Alt + L. Mac : ⌥ (Option) + ⌘ (Command) + L.)를 사용한다. +- [x] else 예약어를 쓰지 않는다. + - 힌트: if 조건절에서 값을 return하는 방식으로 구현하면 else를 사용하지 않아도 된다. + - else를 쓰지 말라고 하니 switch/case로 구현하는 경우가 있는데 switch/case도 허용하지 않는다. + +## 기능 목록 및 commit 로그 요구사항 +- [x] 기능을 구현하기 전에 README.md 파일에 구현할 기능 목록을 정리해 추가한다. +- [x] git의 commit 단위는 앞 단계에서 README.md 파일에 정리한 기능 목록 단위로 추가한다. + - 참고문서: [AngularJS Commit Message Conventions](https://gist.github.com/stephenparish/9941e89d80e2bc58a153) + +--- +## 기능 구현 목록 +- [x] 0에서 9 사이 random 값을 생성한다. +- [x] random 값이 4이상일 경우에 전진하고, 그렇지 않으면 정지한다. +- [x] 자동차 대수와 시도할 회수를 순서대로 입력받는 안내문구를 출력하고 입력받는다. + ``` + 자동차 대수는 몇 대 인가요? + 3 + 시도할 회수는 몇 회 인가요? + 5 + + ``` +- [x] 주어진 횟수 동안 n대의 자동차는 전진하거나 멈출 수 있으며, 횟수마다 실행 결과를 출력한다. + ``` + 실행 결과 + - + - + - + + -- + - + -- + ``` + +--- +## 1차 코멘트 +- [x] View가 상태를 가지고 있는게 맞을까요? +- [x] 객체지향 설계: Car라는 도메인이 있고 그 Car가 Position을 가지고 있고, move하는 행위도 스스로 하는거 아닐까요? +- [x] 상수를 통해 해당 코드의 의미를 나타내보시면 어떨까요? + +## 2차 코멘트 +- [x] String.repeat() 사용 +- [x] [일급컬렉션 적용](https://jojoldu.tistory.com/412) `private final List cars = new ArrayList<>();` +- [x] [테스트 하기 좋은 코드로 인터페이스를 통해 전략패턴](https://tecoble.techcourse.co.kr/post/2020-05-17-appropriate_method_for_test_by_interface/) + - canMove에서 랜덤값을 처리하기 떄문에 실제 테스트를 하기가 어려운 문제가 있습니다. 이유는 position이 1이 될지, 0이 될지 알수가 없기 떄문이죠. + +## 3차 코멘트 +- [x] position을 생성할때 0이 아닌 다른 값을 넣어 생성시 문제가 생길수도 있다! +- [ ] move를 할때마다 RandomNumberGenerator를 만들 이유가 있을까요? 아래의 함수와 차이가 무엇인가요? +- [x] 10이라는 값을 상수를 통해 의미 전달을 해볼까요? +- [x] 현재는 어떻게 보면 통합테스트만 존재한다고 볼수 있습니다. Car나 CarGroup과 같은 메서드들의 단위 테스트를 추가해보시면 어떠실까요? +- [x] 대부분 collection은 Cars라는 복수를 사용하고 있습니다. +- [x] 폴더 구조도 아키텍쳐를 이해하는데에 있어 가장 좋은 방안입니다. 지금은 하나의 폴더에 모든 파일들이 있는데 아키텍쳐에 맞게 폴더(패키지)구조를 짜보시면 어떨까요? \ No newline at end of file diff --git a/src/main/java/.gitkeep b/src/main/java/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/main/java/StringAddCalculator.java b/src/main/java/calculator/util/StringAddCalculator.java similarity index 98% rename from src/main/java/StringAddCalculator.java rename to src/main/java/calculator/util/StringAddCalculator.java index 243a06589ff..42a5ef4cbef 100644 --- a/src/main/java/StringAddCalculator.java +++ b/src/main/java/calculator/util/StringAddCalculator.java @@ -1,3 +1,5 @@ +package calculator.util; + import java.util.Arrays; import java.util.regex.Matcher; import java.util.regex.Pattern; diff --git a/src/main/java/racingcar/application/CarRaceApplication.java b/src/main/java/racingcar/application/CarRaceApplication.java new file mode 100644 index 00000000000..8c08e12e61f --- /dev/null +++ b/src/main/java/racingcar/application/CarRaceApplication.java @@ -0,0 +1,21 @@ +package racingcar.application; + +import racingcar.domain.CarRace; +import racingcar.view.InputView; +import racingcar.view.ResultView; + +import java.util.List; + +public class CarRaceApplication { + public static void main(String[] args) { + InputView input = new InputView(); + int carCount = input.getCarCount(); + int runCount = input.getRunCount(); + + CarRace carRace = new CarRace(carCount, runCount); + List> result = carRace.run(); + + ResultView output = new ResultView(); + output.print(result); + } +} diff --git a/src/main/java/racingcar/domain/Car.java b/src/main/java/racingcar/domain/Car.java new file mode 100644 index 00000000000..b788bb4c8bf --- /dev/null +++ b/src/main/java/racingcar/domain/Car.java @@ -0,0 +1,42 @@ +package racingcar.domain; + +import racingcar.util.NumberGenerator; +import racingcar.util.RandomNumberGenerator; + +public class Car { + private static final int MOVE_THRESHOLD = 4; + private int position; + + public Car() { + this(0); + } + + public Car(int position) { + validatePositive(position); + this.position = position; + } + + private void validatePositive(int position) { + if (position < 0) { + throw new IllegalArgumentException("position은 0 이상이어야 합니다. position:" + position); + } + } + + public int move() { + return move(new RandomNumberGenerator()); + } + + public int move(NumberGenerator numberGenerator) { + if (isMovable(numberGenerator)) incrementPosition(); + return position; + } + + private boolean isMovable(NumberGenerator numberGenerator) { + return numberGenerator.generate() >= MOVE_THRESHOLD; + } + + private void incrementPosition() { + position++; + } + +} \ No newline at end of file diff --git a/src/main/java/racingcar/domain/CarRace.java b/src/main/java/racingcar/domain/CarRace.java new file mode 100644 index 00000000000..b3d6e6df826 --- /dev/null +++ b/src/main/java/racingcar/domain/CarRace.java @@ -0,0 +1,33 @@ +package racingcar.domain; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public class CarRace { + private final int runCount; + private final Cars cars; + + public CarRace(int carCount, int runCount) { + this.runCount = runCount; + this.cars = new Cars(createCars(carCount)); + } + + public List> run() { + return IntStream.range(0, runCount) + .mapToObj(i -> runOnce()) + .collect(Collectors.toList()); + } + + private List createCars(int carCount) { + return IntStream.range(0, carCount) + .mapToObj(i -> new Car()) + .collect(Collectors.toList()); + } + + private List runOnce() { + return cars.move(); + } + +} + diff --git a/src/main/java/racingcar/domain/Cars.java b/src/main/java/racingcar/domain/Cars.java new file mode 100644 index 00000000000..cb7b13b20f2 --- /dev/null +++ b/src/main/java/racingcar/domain/Cars.java @@ -0,0 +1,33 @@ +package racingcar.domain; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public class Cars { + private final List cars; + + public Cars(List cars) { + validateNotEmpty(cars); + validateElementNotNull(cars); + this.cars = new ArrayList<>(cars); + } + + private void validateNotEmpty(List cars) { + if (cars == null || cars.isEmpty()) + throw new IllegalArgumentException("자동차가 없습니다."); + } + + private void validateElementNotNull(List cars) { + boolean hasNullElement = cars.stream().anyMatch(Objects::isNull); + if (hasNullElement) + throw new IllegalArgumentException("자동차가 null입니다."); + } + + public List move() { + return cars.stream() + .map(Car::move) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/racingcar/util/NumberGenerator.java b/src/main/java/racingcar/util/NumberGenerator.java new file mode 100644 index 00000000000..2a4390e8b31 --- /dev/null +++ b/src/main/java/racingcar/util/NumberGenerator.java @@ -0,0 +1,5 @@ +package racingcar.util; + +public interface NumberGenerator { + int generate(); +} diff --git a/src/main/java/racingcar/util/RandomNumberGenerator.java b/src/main/java/racingcar/util/RandomNumberGenerator.java new file mode 100644 index 00000000000..22056b1b6bd --- /dev/null +++ b/src/main/java/racingcar/util/RandomNumberGenerator.java @@ -0,0 +1,13 @@ +package racingcar.util; + +import java.util.Random; + +public class RandomNumberGenerator implements NumberGenerator { + private static final Random random = new Random(); + private static final int RANDOM_UPPER_BOUND = 10; + + @Override + public int generate() { + return random.nextInt(RANDOM_UPPER_BOUND); + } +} diff --git a/src/main/java/racingcar/view/InputView.java b/src/main/java/racingcar/view/InputView.java new file mode 100644 index 00000000000..4c904e6b55c --- /dev/null +++ b/src/main/java/racingcar/view/InputView.java @@ -0,0 +1,31 @@ +package racingcar.view; + +import java.util.Scanner; + +public class InputView { + private final Scanner scanner = new Scanner(System.in); + + public int getCarCount() { + return validatePositiveInt(scanInt("자동차 대수는 몇 대 인가요?")); + } + + public int getRunCount() { + return validatePositiveInt(scanInt("시도할 회수는 몇 회 인가요?")); + } + + private int scanInt(String message) { + System.out.println(message); + int value = scanner.nextInt(); + scanner.nextLine(); + return value; + } + + private int validatePositiveInt(int value) { + if (value <= 0) { + String errorMessage = String.format("입력 값은 양수여야 합니다. value:%d", value); + throw new IllegalArgumentException(errorMessage); + } + return value; + } + +} \ No newline at end of file diff --git a/src/main/java/racingcar/view/ResultView.java b/src/main/java/racingcar/view/ResultView.java new file mode 100644 index 00000000000..7f6c731c01d --- /dev/null +++ b/src/main/java/racingcar/view/ResultView.java @@ -0,0 +1,24 @@ +package racingcar.view; + +import java.util.List; + +public class ResultView { + private static final String PATTERN = "-"; + + public void print(List> result) { + System.out.println(); + System.out.println("실행 결과"); + + for (List runResult : result) { + for (int carPosition : runResult) { + printPattern(carPosition); + } + System.out.println(); + } + System.out.println(); + } + + private void printPattern(int count) { + System.out.println(PATTERN.repeat(count)); + } +} diff --git a/src/test/java/.gitkeep b/src/test/java/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/test/java/SetTest.java b/src/test/java/calculator/util/SetTest.java similarity index 98% rename from src/test/java/SetTest.java rename to src/test/java/calculator/util/SetTest.java index 34c6365a90d..024654767c3 100644 --- a/src/test/java/SetTest.java +++ b/src/test/java/calculator/util/SetTest.java @@ -1,3 +1,5 @@ +package calculator.util; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/StringAddCalculatorTest.java b/src/test/java/calculator/util/StringAddCalculatorTest.java similarity index 98% rename from src/test/java/StringAddCalculatorTest.java rename to src/test/java/calculator/util/StringAddCalculatorTest.java index 6dc34036883..854c58e9f0e 100644 --- a/src/test/java/StringAddCalculatorTest.java +++ b/src/test/java/calculator/util/StringAddCalculatorTest.java @@ -1,3 +1,5 @@ +package calculator.util; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; diff --git a/src/test/java/StringTest.java b/src/test/java/calculator/util/StringTest.java similarity index 98% rename from src/test/java/StringTest.java rename to src/test/java/calculator/util/StringTest.java index feab2ac5ead..178ca65ce7f 100644 --- a/src/test/java/StringTest.java +++ b/src/test/java/calculator/util/StringTest.java @@ -1,3 +1,5 @@ +package calculator.util; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/racingcar/domain/CarRaceTest.java b/src/test/java/racingcar/domain/CarRaceTest.java new file mode 100644 index 00000000000..a94b82baad4 --- /dev/null +++ b/src/test/java/racingcar/domain/CarRaceTest.java @@ -0,0 +1,26 @@ +package racingcar.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import racingcar.domain.CarRace; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CarRaceTest { + + @ParameterizedTest + @CsvSource(delimiter = ',', value = {"2,2", "4,5", "1,3"}) + @DisplayName("자동차 경주를 실행하면, 시도 횟수 동안의 자동차의 위치를 반환한다.") + void runCarRaceAndGetResult(int carCount, int runCount) { + CarRace carRace = new CarRace(carCount, runCount); + List> result = carRace.run(); + + assertThat(result) + .hasSize(runCount) + .allMatch(list -> list.size() == carCount); + } + +} diff --git a/src/test/java/racingcar/domain/CarTest.java b/src/test/java/racingcar/domain/CarTest.java new file mode 100644 index 00000000000..1752960bca0 --- /dev/null +++ b/src/test/java/racingcar/domain/CarTest.java @@ -0,0 +1,36 @@ +package racingcar.domain; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import racingcar.util.NumberGenerator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class CarTest { + + @Test + @DisplayName("자동차 위치가 음수이면, 예외가 발생한다.") + void constructWithNegativePosition() { + Assertions.assertThatThrownBy(() -> new Car(-1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("random 값이 4 이상이면 전진한다.") + void moveIfUpperMovableValue() { + Car car = new Car(); + NumberGenerator numberGenerator = () -> 4; + assertThat(car.move(numberGenerator)).isEqualTo(1); + } + + @Test + @DisplayName("random 값이 4 미만이면 정지한다.") + void stopIfLowerMovableValue() { + Car car = new Car(); + NumberGenerator numberGenerator = () -> 3; + assertThat(car.move(numberGenerator)).isZero(); + } + +} \ No newline at end of file diff --git a/src/test/java/racingcar/domain/CarsTest.java b/src/test/java/racingcar/domain/CarsTest.java new file mode 100644 index 00000000000..5576f6792fc --- /dev/null +++ b/src/test/java/racingcar/domain/CarsTest.java @@ -0,0 +1,43 @@ +package racingcar.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.NullAndEmptySource; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CarsTest { + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("자동차 컬렉션이 null 또는 비어있으면, 예외가 발생한다.") + void construct(List cars) { + assertThatThrownBy(() -> new Cars(cars)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("자동차 컬렉션에 빈 자동차가 포함되어있으면, 예외가 발생한다.") + void constructWithCarsIncludingNull() { + List cars = new ArrayList<>(); + cars.add(null); + + assertThatThrownBy(() -> new Cars(cars)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void move() { + List testCars = Arrays.asList(new Car(), new Car()); + Cars cars = new Cars(testCars); + + assertThat(cars.move()).hasSize(testCars.size()); + } +} \ No newline at end of file diff --git a/src/test/java/racingcar/util/NumberGeneratorTest.java b/src/test/java/racingcar/util/NumberGeneratorTest.java new file mode 100644 index 00000000000..1e66030b1ba --- /dev/null +++ b/src/test/java/racingcar/util/NumberGeneratorTest.java @@ -0,0 +1,17 @@ +package racingcar.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; + +import static org.assertj.core.api.Assertions.assertThat; + +class NumberGeneratorTest { + + @RepeatedTest(value = 30) + @DisplayName("random 값은 0에서 9 사이에서 생성한다.") + void createRandomBetween() { + NumberGenerator numberGenerator = new RandomNumberGenerator(); + assertThat(numberGenerator.generate()).isBetween(0, 9); + } + +} \ No newline at end of file diff --git a/src/test/java/racingcar/view/InputViewTest.java b/src/test/java/racingcar/view/InputViewTest.java new file mode 100644 index 00000000000..97f0cf15b6f --- /dev/null +++ b/src/test/java/racingcar/view/InputViewTest.java @@ -0,0 +1,58 @@ +package racingcar.view; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InputViewTest { + @ParameterizedTest + @CsvSource(delimiter = ',', value = {"3,5", "4,5"}) + @DisplayName("자동차 대수와 시도할 회수를 순서대로 입력받는 안내문구를 출력하고 입력받는다.") + void printGuideMessage(int carNumber, int tryCount) { + String in = String.format("%d%n%d%n", carNumber, tryCount); + System.setIn(new ByteArrayInputStream(in.getBytes())); //setIn() 이후에 new Scanner(System.in)을 호출해야 동작함. + OutputStream out = new ByteArrayOutputStream(); + System.setOut(new PrintStream(out)); + + InputView inputView = new InputView(); + inputView.getCarCount(); + inputView.getRunCount(); + + List expected = List.of("자동차 대수는 몇 대 인가요?", "시도할 회수는 몇 회 인가요?"); + assertThat(out.toString()).contains(expected); + } + + @ParameterizedTest + @ValueSource(strings = {"-3\n", "0\n"}) + @DisplayName("자동차 대수는 양수가 아니면, 에러가 발생한다.") + void throwIfCarCountPositive(String carCount) { + System.setIn(new ByteArrayInputStream(carCount.getBytes())); + + InputView inputView = new InputView(); + + assertThatThrownBy(inputView::getCarCount) + .isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"0\n", "-1\n"}) + @DisplayName("시도할 회수는 양수가 아니면, 에러가 발생한다.") + void throwIfRunCountPositive(String runCount) { + System.setIn(new ByteArrayInputStream(runCount.getBytes())); + + InputView inputView = new InputView(); + + assertThatThrownBy(inputView::getRunCount) + .isInstanceOf(IllegalArgumentException.class); + } +} \ No newline at end of file diff --git a/src/test/java/racingcar/view/ResultViewTest.java b/src/test/java/racingcar/view/ResultViewTest.java new file mode 100644 index 00000000000..05dcf8be470 --- /dev/null +++ b/src/test/java/racingcar/view/ResultViewTest.java @@ -0,0 +1,29 @@ +package racingcar.view; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ResultViewTest { + + @Test + @DisplayName("자동차 경주의 실행 결과를 출력한다.") + void printStartResult() { + OutputStream out = new ByteArrayOutputStream(); + System.setOut(new PrintStream(out)); + + List> result = List.of(List.of(1, 2, 3), List.of(1, 2, 3)); + + ResultView resultView = new ResultView(); + resultView.print(result); + + assertThat(out.toString()).containsPattern("실행 결과\\R((-*\\R)*\\R)*"); + } + +} \ No newline at end of file