diff --git a/README.md b/README.md index d0286c859f..ee84e4bea4 100644 --- a/README.md +++ b/README.md @@ -1 +1,101 @@ # java-racingcar-precourse + +우아한테크코스 8기 프리코스 2주차 괴제 내용입니다. + +## 프로젝트 설명 + +쉼표로 구분된 자동차 이름을 입력받아 경주를 준비, 시도할 횟수를 입력받아 입력된 횟수만큼 경주를 실행한다. +각 자동차는 0-9 사이의 무작위 값을 부여받아 무작위 값이 4 이상일 경우 해당 자동차는 1칸 전진한다. +각 라운드별 실행 결과를 실시간으로 출력한다. +모든 경주가 완료된 후, 가장 멀리 이동한 자동차를 최종 우승자로 선정하여 출력한다. + +## 기능 목록 + +1. 입력 기능 +- [x] 자동차 이름을 쉼표 기준으로 입력받기 +- [x] 경주 횟수 입력받기 + +2. 예외 처리 + +- [x] 자동차 이름이 5자를 초과하는 경우 +- [x] 자동차 이름이 null, 빈 문자열이거나 공백만으로 이루어진 경우 +- [x] 중복된 자동차 이름을 입력한 경우 +- [x] 경주 횟수에 정수가 아닌 값을 입력한 경우 +- [x] 경주 횟수에 자연수가 아닌 값을 입력한 경우 + +3. 메인 로직 + +- [x] 입력받은 자동차 이름을 쉼표 기준으로 나누어 자동차 객체 목록 생성 +- [x] 무작위 값이 4 이상인 경우 자동차를 전진시키기 +- [x] 입력된 횟수만큼 자동차 경주를 실행하기 +- [x] 최종 우승자 결정하기(가장 먼 위치의 자동차를 찾아내기) + +4. 출력 기능 + +- [x] 횟수별 자동차의 이름과 이동한 거리를 출력하기 +- [x] 최종 우승자 출력하기(공동 우승 시 쉼표로 구분) + +## 사용 기술 + +- Language: Java 21 +- Build Tool: Gradle +- Test: JUnit 5, AssertJ +- Library: camp.nextstep.edu.missionutils (Console, Randoms) + +## 아키텍처 및 설계 원칙 + +본 프로젝트는 객체 지향 설계 원칙을 준수하도록 개발되었습니다. + +### 1. MVC 패턴 (Model-View-Controller) +Controller +- RacingGameController: View와 Model을 중재하며 게임의 전체 흐름(입력 ➔ 실행 ➔ 결과)을 제어 + +Model +- Car: name과 position을 가지며 position을 1 증가시키는 move() 메서드를 갖는다 +- RacingGame: List를 관리하며 findWinners()와 같은 핵심 로직을 수행 +- CarFactory: 이름 문자열을 받아 List 생성하는 Factory 객체 +- Validator: 이름 및 횟수 검증 로직을 static 메서드로 제공하는 유틸리티 객체 + +View +- InputView: 사용자의 콘솔 입력을 담당 +- OutputView: 콘솔 출력을 담당 + +### 2. TDD (Test-Driven Development) +Model 계층의 모든 비즈니스 로직과 예외 상황에 대해 JUnit 5와 AssertJ를 사용하여 단위 테스트를 우선 작성하고 이를 통과하는 코드를 구현했다. + +### 3. 의존성 주입 (Dependency Injection) +Application(main)이 InputView, OutputView, CarFactory 등 필요한 객체를 생성하여 RacingGameController의 생성자로 주입한다. +- Controller가 View의 구체적인 구현이 아닌 인스턴스에 의존하게 된다. +- Controller와 View 간의 결합도가 낮아지며 Controller의 테스트가 용이하다. + +### 4. 단일 책임 원칙 (SRP) + +각 객체가 하나의 책임만 갖도록 분리했습니다. +CarFactory는 '생성', Validator는 '검증', RacingGame은 '게임 로직', Controller는 '흐름 제어'의 책임을 각각 담당합니다. + +## 실행 방법 + +1. Application.main() 메서드를 실행 +2. "경주할 자동차 이름을 입력하세요..."에 따라 이름을 입력한다.(이름은 쉼표로 구분한다) +3. "시도할 횟수는 몇 회인가요?"에 따라 횟수를 입력한다. +4. 실행 결과를 확인한다. + +## 프로젝트 구조 + +``` +├── main +│ └── java +│ └── racingcar +│ ├── controller +│ │ └── RacingGameController.java # 게임의 전체 흐름(입력 ➔ 실행 ➔ 결과)을 제어 +│ ├── model +│ │ ├── Car.java # 자동차 name, position, move() 포함 +│ │ ├── CarFactory.java # List을 생성 +│ │ ├── RacingGame.java # findWinners()와 같은 핵심 로직을 수행 +│ │ └── Validator.java # 이름 및 횟수 검증 +│ ├── view +│ │ ├── InputView.java +│ │ └── OutputView.java +│ └── Application.java +└── test +``` diff --git a/src/main/java/racingcar/Application.java b/src/main/java/racingcar/Application.java index a17a52e724..e9236d15ce 100644 --- a/src/main/java/racingcar/Application.java +++ b/src/main/java/racingcar/Application.java @@ -1,7 +1,19 @@ package racingcar; +import racingcar.controller.RacingGameController; +import racingcar.model.CarFactory; +import racingcar.view.InputView; +import racingcar.view.OutputView; + public class Application { public static void main(String[] args) { - // TODO: 프로그램 구현 + InputView inputView = new InputView(); + OutputView outputView = new OutputView(); + CarFactory carFactory = new CarFactory(); + + RacingGameController racingGameController = new RacingGameController( + inputView, outputView, carFactory + ); + racingGameController.run(); } -} +} \ No newline at end of file diff --git a/src/main/java/racingcar/controller/RacingGameController.java b/src/main/java/racingcar/controller/RacingGameController.java new file mode 100644 index 0000000000..6494de46fb --- /dev/null +++ b/src/main/java/racingcar/controller/RacingGameController.java @@ -0,0 +1,55 @@ +package racingcar.controller; + +import racingcar.model.Car; +import racingcar.model.CarFactory; +import racingcar.model.RacingGame; +import racingcar.view.InputView; +import racingcar.view.OutputView; + +import java.util.List; + +public class RacingGameController { + private final InputView inputView; + private final OutputView outputView; + private final CarFactory carFactory; + + public RacingGameController(InputView inputView, OutputView outputView, CarFactory carFactory) { + this.inputView = inputView; + this.outputView = outputView; + this.carFactory = carFactory; + } + + public void run() { + // 1. 준비 + List cars = setupCars(); + int tryCount = setupTryCount(); + RacingGame game = new RacingGame(cars); + // 2. 실행 + playRacingGame(game, tryCount); + // 3. 결과 + showGameResult(game); + } + + private List setupCars() { + String carNameInput = inputView.readCarNames(); + return carFactory.createCarList(carNameInput); + } + + private int setupTryCount() { + String tryCountInput = inputView.readTryCount(); + return Integer.parseInt(tryCountInput); + } + + private void playRacingGame(RacingGame game, int tryCount) { + outputView.printResultHeader(); + for (int i = 0; i < tryCount; i++) { + game.runOneRound(); + outputView.printCurrentStatus(game.getCarList()); + } + } + + private void showGameResult(RacingGame game) { + List winners = game.findWinners(); + outputView.printWinner(winners); + } +} diff --git a/src/main/java/racingcar/model/Car.java b/src/main/java/racingcar/model/Car.java new file mode 100644 index 0000000000..f4a352d82d --- /dev/null +++ b/src/main/java/racingcar/model/Car.java @@ -0,0 +1,24 @@ +package racingcar.model; + +public class Car { + private final String name; + private int position; + private static final int INCREASE_POSITION = 1; + + public Car(String name) { + this.name = name; + Validator.validateName(name); + } + + public int getPosition() { + return this.position; + } + + public String getName() { + return this.name; + } + + public void move() { + this.position += INCREASE_POSITION; + } +} diff --git a/src/main/java/racingcar/model/CarFactory.java b/src/main/java/racingcar/model/CarFactory.java new file mode 100644 index 0000000000..d457625f87 --- /dev/null +++ b/src/main/java/racingcar/model/CarFactory.java @@ -0,0 +1,22 @@ +package racingcar.model; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class CarFactory { + private static final String DELIMITER = ","; + + public List createCarList(String carNameInput) { + String[] names = carNameInput.split(DELIMITER); + List nameList = Arrays.asList(names); + + Validator.validateDuplicateNames(nameList); + + List carList = new ArrayList<>(); + for (String name : names) { + carList.add(new Car(name)); + } + return carList; + } +} diff --git a/src/main/java/racingcar/model/RacingGame.java b/src/main/java/racingcar/model/RacingGame.java new file mode 100644 index 0000000000..b87b326bb9 --- /dev/null +++ b/src/main/java/racingcar/model/RacingGame.java @@ -0,0 +1,45 @@ +package racingcar.model; + +import java.util.ArrayList; +import java.util.List; +import static camp.nextstep.edu.missionutils.Randoms.pickNumberInRange; + +public class RacingGame { + private final List carList; + private static final int MIN_INCREASE_CONDITION = 4; + + public RacingGame(List carList) { + this.carList = carList; + } + + public List findWinners() { + int maxPosition = findMaxPosition(); + List winners = new ArrayList<>(); + + for(Car car : carList) { + if(car.getPosition() == maxPosition) { + winners.add(car.getName()); + } + } + return winners; + } + + private int findMaxPosition() { + int maxPosition = 0; + for (Car car : carList) { + maxPosition = Math.max(maxPosition, car.getPosition()); + } + return maxPosition; + } + + public void runOneRound() { + for(Car car : carList) { + int randomNumber = pickNumberInRange(0,9); + if(randomNumber >= MIN_INCREASE_CONDITION) car.move(); + } + } + + public List getCarList() { + return carList; + } +} diff --git a/src/main/java/racingcar/model/Validator.java b/src/main/java/racingcar/model/Validator.java new file mode 100644 index 0000000000..f93d4f6161 --- /dev/null +++ b/src/main/java/racingcar/model/Validator.java @@ -0,0 +1,34 @@ +package racingcar.model; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class Validator { + private static final int MAX_NAME_LENGTH = 5; + + public static void validateName(String name) { + if(name == null) throw new IllegalArgumentException("이름은 null일 수 없습니다"); + + if(name.isBlank()) throw new IllegalArgumentException("이름이 비어있습니다"); + + if(name.length() > MAX_NAME_LENGTH) throw new IllegalArgumentException("이름은 5자 이하만 가능합니다"); + } + + public static void validateTryCount(String tryCount) { + try { + int count = Integer.parseInt(tryCount); + if(count < 1) throw new IllegalArgumentException("입력값이 자연수가 아닙니다"); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("입력값이 정수가 아닙니다"); + } + } + + public static void validateDuplicateNames(List names) { + Set uniqueNames = new HashSet<>(names); + + if (uniqueNames.size() != names.size()) { + throw new IllegalArgumentException("중복된 자동차 이름이 있습니다."); + } + } +} diff --git a/src/main/java/racingcar/view/InputView.java b/src/main/java/racingcar/view/InputView.java new file mode 100644 index 0000000000..f6c584c39c --- /dev/null +++ b/src/main/java/racingcar/view/InputView.java @@ -0,0 +1,21 @@ +package racingcar.view; + +import racingcar.model.Validator; + +import static camp.nextstep.edu.missionutils.Console.readLine; + +public class InputView { + public String readCarNames() { + System.out.println("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"); + return readLine(); + } + + public String readTryCount() { + System.out.println("시도할 횟수는 몇 회인가요?"); + + String inputTryCount = readLine(); + Validator.validateTryCount(inputTryCount); + + return inputTryCount; + } +} diff --git a/src/main/java/racingcar/view/OutputView.java b/src/main/java/racingcar/view/OutputView.java new file mode 100644 index 0000000000..b04866af56 --- /dev/null +++ b/src/main/java/racingcar/view/OutputView.java @@ -0,0 +1,24 @@ +package racingcar.view; + +import racingcar.model.Car; + +import java.util.List; + +public class OutputView { + public void printResultHeader() { + System.out.println("\n실행 결과"); + } + + public void printCurrentStatus(List CarList) { + for (Car car : CarList) { + System.out.println(car.getName()+ " : " + "-".repeat(car.getPosition())); + } + System.out.println(); + } + + public void printWinner(List winners) { + System.out.print("최종 우승자 : "); + String result = String.join(", ", winners); + System.out.print(result); + } +} diff --git a/src/test/java/racingcar/model/CarFactoryTest.java b/src/test/java/racingcar/model/CarFactoryTest.java new file mode 100644 index 0000000000..6503251ceb --- /dev/null +++ b/src/test/java/racingcar/model/CarFactoryTest.java @@ -0,0 +1,27 @@ +package racingcar.model; + +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 CarFactoryTest { + + @DisplayName("쉼표로 구분된 문자열로 Car 리스트를 생성한다") + @Test + void createCarsFromNames() { + CarFactory carFactory = new CarFactory(); + + String carNameInput = "pobi,woni,jun"; + + List cars = carFactory.createCarList(carNameInput); + + assertThat(cars).hasSize(3); + + assertThat(cars) + .extracting(Car::getName) + .containsExactly("pobi", "woni", "jun"); + } +} diff --git a/src/test/java/racingcar/model/CarTest.java b/src/test/java/racingcar/model/CarTest.java new file mode 100644 index 0000000000..9e54b0bba6 --- /dev/null +++ b/src/test/java/racingcar/model/CarTest.java @@ -0,0 +1,48 @@ +package racingcar.model; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class CarTest { + private Car car; + private static final String defaultName = "name"; + + @BeforeEach + void setUp() { + car = new Car(defaultName); + } + + @DisplayName("Car 객체 생성 시 이름을 검증한다") + @Test + void createCarWithInvalidName() { + assertThatThrownBy(() -> new Car("123456")) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("자동차의 초기 position은 0이다") + @Test + void initCar() { + int position = car.getPosition(); + + assertThat(position).isEqualTo(0); + } + + @DisplayName("Car의 이름과 position을 반환한다") + @Test + void getNameAndPosition() { + assertThat(car.getName()).isEqualTo(defaultName); + assertThat(car.getPosition()).isEqualTo(0); + } + + @DisplayName("move() 호출 시 position이 1 증가한다") + @Test + void moveCarWhenNumberIsFourOrMore() { + car.move(); + + assertThat(car.getPosition()).isEqualTo(1); + } +} diff --git a/src/test/java/racingcar/model/RacingGameTest.java b/src/test/java/racingcar/model/RacingGameTest.java new file mode 100644 index 0000000000..f5c394ca60 --- /dev/null +++ b/src/test/java/racingcar/model/RacingGameTest.java @@ -0,0 +1,63 @@ +package racingcar.model; + +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 RacingGameTest { + + @DisplayName("가장 많이 전진한 자동차가 한 명일 때 우승자로 반환한다") + @Test + void findSingleWinner() { + Car car1 = new Car("pobi"); + Car car2 = new Car("woni"); + Car car3 = new Car("jun"); + + // [position] car1: 2, car2: 1, car3: 0 + car1.move(); + car1.move(); + car2.move(); + + RacingGame game = new RacingGame(List.of(car1,car2,car3)); + + List winners = game.findWinners(); + + assertThat(winners).containsOnly("pobi"); + } + + @DisplayName("가장 많이 전진한 자동차가 여러 명일 때 모두 반환한다") + @Test + void findMultipleWinner() { + Car car1 = new Car("pobi"); + Car car2 = new Car("woni"); + Car car3 = new Car("jun"); + + // [positon] car1: 1, car2: 1, car3: 0 + car1.move(); + car2.move(); + + RacingGame game = new RacingGame(List.of(car1, car2, car3)); + + List winners = game.findWinners(); + + assertThat(winners).containsExactlyInAnyOrder("pobi", "woni"); + } + + @DisplayName("현재 자동차 목록을 반환한다") + @Test + void getCarsList() { + Car car1 = new Car("pobi"); + Car car2 = new Car("woni"); + Car car3 = new Car("jun"); + + List carList = List.of(car1, car2, car3); + RacingGame game = new RacingGame(carList); + + List result = game.getCarList(); + + assertThat(result).isEqualTo(carList); + } +} diff --git a/src/test/java/racingcar/model/ValidatorTest.java b/src/test/java/racingcar/model/ValidatorTest.java new file mode 100644 index 0000000000..55fc16c113 --- /dev/null +++ b/src/test/java/racingcar/model/ValidatorTest.java @@ -0,0 +1,73 @@ +package racingcar.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +public class ValidatorTest { + @DisplayName("자동차의 이름이 5자를 초과하면 IllegalArgumentException이 발생한다") + @Test + void validateNameLengthExceeds() { + String longName = "123456"; + + assertThatThrownBy(() -> Validator.validateName(longName)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("자동차의 이름이 빈 문자열이면 예외가 발생한다") + @ParameterizedTest + @ValueSource(strings = {"", " ", " "}) + void validateNameIsBlank(String blankName) { + assertThatThrownBy(() -> Validator.validateName(blankName)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("자동차의 이름이 null이면 예외가 발생한다") + @Test + void validateNameIsNull() { + assertThatThrownBy(() -> Validator.validateName(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("정상적인 이름은 예외가 발생하지 않는다") + @Test + void validateNameSuccess() { + assertDoesNotThrow(() -> Validator.validateName("pobi")); + } + + @DisplayName("시도할 횟수가 문자면 예외가 발생한다") + @Test + void validateTryCountIsNotNumber() { + assertThatThrownBy(() -> Validator.validateTryCount("a")) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("시도할 횟수가 정수가 아니면 예외가 발생한다") + @Test + void validateTryCountIsNotInteger() { + assertThatThrownBy(() -> Validator.validateTryCount("1.1")) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("시도할 횟수가 자연수가 아니면 예외가 발생한다") + @Test + void validateTryCountIsNotNaturalNumber() { + assertThatThrownBy(() -> Validator.validateTryCount("-1")) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("이름을 중복 입력하면 예외가 발생한다") + @Test + void validateDuplicateName() { + List duplicateNames = List.of("pobi", "woni", "pobi"); + + assertThatThrownBy(() -> Validator.validateDuplicateNames(duplicateNames)) + .isInstanceOf(IllegalArgumentException.class); + } +}