diff --git a/README.md b/README.md index d0286c859f..7f33032395 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ # java-racingcar-precourse +## 과제 목표 +- 사용자로부터 자동차 이름과 시도 횟수를 입력받는다. +- 각 자동차가 전진 또는 정지하며 경주를 진행한다. +- 최종 우승자를 출력한다. +--- +## 기능 요구 사항 +- 자동차 이름은 쉼표(,)로 구분하며, 각 이름은 **5자 이하**여야 한다. +- 시도 횟수는 숫자로 입력받는다. +- 전진 조건은 `0~9` 사이 무작위 값 중 **4 이상일 경우 전진**한다. +- 시도 횟수만큼 게임을 반복하고, 매 라운드마다 결과를 출력한다. +- 최종 우승자는 여러 명일 수도 있다. +--- +## 기능 목록 +- [ ] 자동차 이름 입력받기 +- [ ] 시도 횟수 입력받기 +- [ ] 입력값 검증 (이름 길이, 공백, 음수 등) +- [ ] 랜덤 전진 조건 생성 +- [ ] 전진 결과 출력 +- [ ] 우승자 판별 및 출력 +--- +## 테스트 요구 사항 +- `ApplicationTest` 또는 각 기능 단위 테스트 작성 +- 입력/출력에 대한 검증 포함 \ No newline at end of file diff --git a/src/main/java/racingcar/Application.java b/src/main/java/racingcar/Application.java index a17a52e724..3cceec5553 100644 --- a/src/main/java/racingcar/Application.java +++ b/src/main/java/racingcar/Application.java @@ -1,7 +1,33 @@ package racingcar; +import racingcar.controller.RacingGame; +import racingcar.domain.*; +import racingcar.domain.strategy.RandomMoveStrategy; +import racingcar.util.Validator; +import racingcar.view.InputView; +import racingcar.view.OutputView; + +import java.util.List; + public class Application { public static void main(String[] args) { // TODO: 프로그램 구현 + String rawNames = InputView.readNames(); + Validator.validateNames(List.of(rawNames.split(","))); + Cars cars = Cars.fromCsv(rawNames); + + String rawAttempts = InputView.readAttempts(); + int attempts = Validator.validateAttempts(rawAttempts); + + OutputView.printStart(); + + RandomMoveStrategy strategy = new RandomMoveStrategy(); + for (int i = 0; i < attempts; i++) { + cars.moveAll(new RandomMoveStrategy()); + OutputView.printRound(cars); + } + + List winners = WinnerCalculator.calculate(cars); + OutputView.printWinners(winners); } } diff --git a/src/main/java/racingcar/controller/RacingGame.java b/src/main/java/racingcar/controller/RacingGame.java new file mode 100644 index 0000000000..9be07499b4 --- /dev/null +++ b/src/main/java/racingcar/controller/RacingGame.java @@ -0,0 +1,25 @@ +package racingcar.controller; + +import racingcar.domain.Cars; +import racingcar.domain.strategy.MoveStrategy; + +public class RacingGame { + private final Cars cars; + private final int attempts; + private final MoveStrategy strategy; + + public RacingGame(Cars cars, int attempts, MoveStrategy strategy) { + this.cars = cars; + this.attempts = attempts; + this.strategy = strategy; + } + public void play() { + // 시도 횟수만큼 반복 + for (int i = 0; i < attempts; i++) { + cars.moveAll(strategy); + } + } + public Cars getCars() { + return cars; + } +} diff --git a/src/main/java/racingcar/domain/Car.java b/src/main/java/racingcar/domain/Car.java new file mode 100644 index 0000000000..a448f4928a --- /dev/null +++ b/src/main/java/racingcar/domain/Car.java @@ -0,0 +1,38 @@ +package racingcar.domain; + +import racingcar.domain.strategy.MoveStrategy; + +public class Car { + private static final int MAX_NAME_LENGTH = 5; + + private final String name; + private int position = 0; + + public Car(String name) { + validateName(name); + this.name = name; + } + private void validateName(String name) { + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("자동차 이름은 비어 있을 수 없습니다."); + } + if (name.trim().length() > MAX_NAME_LENGTH) { + throw new IllegalArgumentException("자동차 이름은 5자 이하여야 합니다."); + } + } + public void moveIf(MoveStrategy strategy) { + if (strategy.movable()) { + this.position++; + } + } + public String getName() { + return this.name; + } + public int getPosition() { + return this.position; + } + @Override + public String toString() { + return String.format("Car{name = '%s', position = %d}", name, position); + } +} diff --git a/src/main/java/racingcar/domain/Cars.java b/src/main/java/racingcar/domain/Cars.java new file mode 100644 index 0000000000..bfb4379348 --- /dev/null +++ b/src/main/java/racingcar/domain/Cars.java @@ -0,0 +1,59 @@ +package racingcar.domain; + +import racingcar.domain.strategy.MoveStrategy; + +import java.util.*; +import java.util.stream.Stream; + +public class Cars implements Iterable { + private final List cars; + + private Cars(List cars) { + this.cars = List.copyOf(cars); + } + public static Cars fromCsv(String csv) { + List names = Arrays.stream(csv.split(",")).map(String::trim).filter(name -> !name.isEmpty()).toList(); + + validateDuplicate(names); + return fromNames(names); + } + public static Cars fromNames(List names) { + if (names.isEmpty()) { + throw new IllegalArgumentException("자동차 이름 목록이 비어 있습니다."); + } + + List cars = names.stream().map(Car::new).toList(); + + return new Cars(cars); + } + private static void validateCsv(String csv) { + if (csv == null || csv.trim().isEmpty()) { + throw new IllegalArgumentException("자동차 이름을 입력해야 합니다."); + } + } + private static void validateDuplicate(List names) { + Set distinctNames = new HashSet<>(names); + if (distinctNames.size() != names.size()) { + throw new IllegalArgumentException("자동차 이름은 중복될 수 없습니다."); + } + } + public void moveAll(MoveStrategy strategy) { + for (Car car : cars) { + car.moveIf(strategy); + } + } + + public List toList() { + return Collections.unmodifiableList(cars); + } + + public Stream stream() { + return cars.stream(); + } + + @Override + public Iterator iterator() { + return cars.iterator(); + } + +} diff --git a/src/main/java/racingcar/domain/WinnerCalculator.java b/src/main/java/racingcar/domain/WinnerCalculator.java new file mode 100644 index 0000000000..c2d6f31f05 --- /dev/null +++ b/src/main/java/racingcar/domain/WinnerCalculator.java @@ -0,0 +1,32 @@ +package racingcar.domain; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class WinnerCalculator { + + private WinnerCalculator() {} + + public static List calculate(final Cars cars) { + List list = cars.toList(); + if (list.isEmpty()) { + return Collections.emptyList(); + } + + int max = 0; + for (Car car : list) { + if (car.getPosition() > max) { + max = car.getPosition(); + } + } + + List winners = new ArrayList<>(); + for (Car car : list) { + if (car.getPosition() == max) { + winners.add(car.getName()); + } + } + return Collections.unmodifiableList(winners); + } +} diff --git a/src/main/java/racingcar/domain/strategy/MoveStrategy.java b/src/main/java/racingcar/domain/strategy/MoveStrategy.java new file mode 100644 index 0000000000..e1b22eae54 --- /dev/null +++ b/src/main/java/racingcar/domain/strategy/MoveStrategy.java @@ -0,0 +1,6 @@ +package racingcar.domain.strategy; + +@FunctionalInterface +public interface MoveStrategy { + boolean movable(); +} diff --git a/src/main/java/racingcar/domain/strategy/RandomMoveStrategy.java b/src/main/java/racingcar/domain/strategy/RandomMoveStrategy.java new file mode 100644 index 0000000000..934d2142d7 --- /dev/null +++ b/src/main/java/racingcar/domain/strategy/RandomMoveStrategy.java @@ -0,0 +1,14 @@ +package racingcar.domain.strategy; + +import camp.nextstep.edu.missionutils.Randoms; + +public class RandomMoveStrategy implements MoveStrategy { + private static final int START_NUM = 0; + private static final int END_NUM = 9; + private static final int MOVE_THRESHOLD = 4; + + @Override + public boolean movable() { + return Randoms.pickNumberInRange(START_NUM, END_NUM) >= MOVE_THRESHOLD; + } +} diff --git a/src/main/java/racingcar/util/Validator.java b/src/main/java/racingcar/util/Validator.java new file mode 100644 index 0000000000..6a5fa0a8e4 --- /dev/null +++ b/src/main/java/racingcar/util/Validator.java @@ -0,0 +1,58 @@ +package racingcar.util; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public final class Validator { + + private Validator() {} + + public static final String ERR_EMPTY_NAME = "자동차 이름은 비어 있을 수 없습니다."; + public static final String ERR_NAME_LENGTH = "자동차 이름은 5자 이하여야 합니다."; + public static final String ERR_DUPLICATE_NAME = "자동차 이름은 중복될 수 없습니다."; + public static final String ERR_ATTEMPTS_NOT_NUMBER = "시도 횟수는 숫자여야 합니다."; + public static final String ERR_ATTEMPTS_RANGE = "시도 횟수는 1 이상의 정수여야 합니다."; + + public static void validateNames(final List rawNames) { + if (rawNames == null || rawNames.isEmpty()) { + throw new IllegalArgumentException(ERR_EMPTY_NAME); + } + + final Set seen = new HashSet<>(); + for (String raw : rawNames) { + String name = ""; + if (raw != null) { + name = raw.trim(); + } + + if (name.isEmpty()) { + throw new IllegalArgumentException(ERR_EMPTY_NAME); + } + if (name.length() > 5) { + throw new IllegalArgumentException(ERR_NAME_LENGTH); + } + if (!seen.add(name)) { + throw new IllegalArgumentException(ERR_DUPLICATE_NAME); + } + } + } + + public static int validateAttempts(final String raw) { + if (raw == null || raw.isBlank()) { + throw new IllegalArgumentException(ERR_ATTEMPTS_NOT_NUMBER); + } + + for (char c : raw.toCharArray()) { + if (!Character.isDigit(c)) { + throw new IllegalArgumentException(ERR_ATTEMPTS_NOT_NUMBER); + } + } + + int attempts = Integer.parseInt(raw); + if (attempts < 1) { + throw new IllegalArgumentException(ERR_ATTEMPTS_RANGE); + } + return attempts; + } +} diff --git a/src/main/java/racingcar/view/InputView.java b/src/main/java/racingcar/view/InputView.java new file mode 100644 index 0000000000..7761727301 --- /dev/null +++ b/src/main/java/racingcar/view/InputView.java @@ -0,0 +1,20 @@ +package racingcar.view; + +import camp.nextstep.edu.missionutils.Console; + +public final class InputView { + + private InputView() {} + + public static String readNames() { + System.out.println("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"); + String input = Console.readLine(); + return input; + } + + public static String readAttempts() { + System.out.println("시도할 횟수는 몇 회인가요?"); + String input = Console.readLine(); + return input; + } +} diff --git a/src/main/java/racingcar/view/OutputView.java b/src/main/java/racingcar/view/OutputView.java new file mode 100644 index 0000000000..0df2433dad --- /dev/null +++ b/src/main/java/racingcar/view/OutputView.java @@ -0,0 +1,40 @@ +package racingcar.view; + +import java.util.List; +import racingcar.domain.Car; +import racingcar.domain.Cars; + +public final class OutputView { + + private OutputView() {} + + public static void printStart() { + System.out.println("실행 결과"); + } + + public static void printRound(final Cars cars) { + for (Car car : cars.toList()) { + printCarState(car.getName(), car.getPosition()); + } + System.out.println(); + } + + private static void printCarState(final String name, final int position) { + StringBuilder bar = new StringBuilder(); + for (int i = 0; i < position; i++) { + bar.append("-"); + } + System.out.println(name + " : " + bar); + } + + public static void printWinners(final List winners) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < winners.size(); i++) { + sb.append(winners.get(i)); + if (i < winners.size() - 1) { + sb.append(", "); + } + } + System.out.println("최종 우승자 : " + sb); + } +} diff --git a/src/test/java/racingcar/CarTest.java b/src/test/java/racingcar/CarTest.java new file mode 100644 index 0000000000..932b9da562 --- /dev/null +++ b/src/test/java/racingcar/CarTest.java @@ -0,0 +1,22 @@ +package racingcar; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import racingcar.domain.Car; + +public class CarTest { + + @Test + void 이동_전략이_true이면_한칸_이동한다() { + Car car = new Car("pobi"); + car.moveIf(() -> true); + assertThat(car.getPosition()).isEqualTo(1); + } + + @Test + void 이동_전략이_false이면_그대로_정지한다() { + Car car = new Car("pobi"); + car.moveIf(() -> false); + assertThat(car.getPosition()).isEqualTo(0); + } +} diff --git a/src/test/java/racingcar/CarsTest.java b/src/test/java/racingcar/CarsTest.java new file mode 100644 index 0000000000..65263788bf --- /dev/null +++ b/src/test/java/racingcar/CarsTest.java @@ -0,0 +1,29 @@ +package racingcar; + +import org.junit.jupiter.api.Test; +import racingcar.domain.Cars; +import racingcar.domain.Car; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +public class CarsTest { + + @Test + void CSV_공백_트림_적용되어_Car_생성() { + Cars cars = Cars.fromCsv("pobi, woni, jun"); + assertThat(cars).isNotNull(); + } + + @Test + void 중복_이름이_있으면_예외() { + assertThatThrownBy(() -> Cars.fromCsv("pobi, woni, pobi")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void fromNames_정상_생성() { + Cars cars = Cars.fromNames(List.of("pobi", "woni", "jun")); + assertThat(cars).isNotNull(); + } +} diff --git a/src/test/java/racingcar/RacingGameTest.java b/src/test/java/racingcar/RacingGameTest.java new file mode 100644 index 0000000000..1ad5a54c06 --- /dev/null +++ b/src/test/java/racingcar/RacingGameTest.java @@ -0,0 +1,52 @@ +package racingcar; + +import org.junit.jupiter.api.Test; +import racingcar.controller.RacingGame; +import racingcar.domain.Cars; +import racingcar.domain.Car; +import racingcar.domain.strategy.MoveStrategy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.map; + +public class RacingGameTest { + + @Test + void 시도수만큼_이동호출_항상_이동() { + Cars cars = Cars.fromCsv("pobi, woni, jun"); + MoveStrategy alwaysMove = () -> true; + int attempts = 5; + + RacingGame game = new RacingGame(cars, attempts, alwaysMove); + + game.play(); + + cars.toList().forEach(car -> assertThat(car.getPosition()).isEqualTo(attempts)); + } + + @Test + void 시도_횟수가_0일때_항상_이동() { + Cars cars = Cars.fromCsv("pobi, woni, jun"); + MoveStrategy alwaysMove = () -> true; + int attempts = 0; + + RacingGame game = new RacingGame(cars, attempts, alwaysMove); + + game.play(); + + cars.toList().forEach(car -> assertThat(car.getPosition()).isZero()); + } + + @Test + void 시도수만큼_이동호출_항상_정지() { + Cars cars = Cars.fromCsv("pobi, woni, jun"); + MoveStrategy neverMove = () -> false; + int attempts = 5; + + RacingGame game = new RacingGame(cars, attempts, neverMove); + + game.play(); + + cars.toList().forEach(car -> assertThat(car.getPosition()).isZero()); + } +} diff --git a/src/test/java/racingcar/RandomMoveStrategyTest.java b/src/test/java/racingcar/RandomMoveStrategyTest.java new file mode 100644 index 0000000000..357a63b18e --- /dev/null +++ b/src/test/java/racingcar/RandomMoveStrategyTest.java @@ -0,0 +1,15 @@ +package racingcar; + +import org.junit.jupiter.api.Test; +import racingcar.domain.strategy.RandomMoveStrategy; + +import static org.assertj.core.api.Assertions.assertThatNoException; + +public class RandomMoveStrategyTest { + + @Test + void 랜덤_전략_호출_예외없이_동작한다() { + RandomMoveStrategy strategy = new RandomMoveStrategy(); + assertThatNoException().isThrownBy(strategy::movable); + } +} diff --git a/src/test/java/racingcar/ValidatorTest.java b/src/test/java/racingcar/ValidatorTest.java new file mode 100644 index 0000000000..4fa6712eaa --- /dev/null +++ b/src/test/java/racingcar/ValidatorTest.java @@ -0,0 +1,48 @@ +package racingcar; + +import org.junit.jupiter.api.Test; +import racingcar.util.Validator; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; + +public class ValidatorTest { + + @Test + void 이름목록_정상() { + Validator.validateNames(List.of("pobi", "woni", "jun")); + } + + @Test + void 이름_빈값이면_예외() { + assertThatThrownBy(() -> Validator.validateNames(List.of("pobi", "", "jun"))).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 이름_길이초과_예외() { + assertThatThrownBy(() -> Validator.validateNames(List.of("pobi", "abcdef"))).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 이름_중복_예외() { + assertThatThrownBy(() -> Validator.validateNames(List.of("pobi", "pobi"))).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 시도횟수_숫자아님_예외() { + assertThatThrownBy(() -> Validator.validateAttempts("3a")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 시도횟수_0이하_예외() { + assertThatThrownBy(() -> Validator.validateAttempts("0")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 시도횟수_정상반환() { + int v = Validator.validateAttempts("5"); + assertThat(v).isEqualTo(5); + } +} diff --git a/src/test/java/racingcar/WinnerCalculatorTest.java b/src/test/java/racingcar/WinnerCalculatorTest.java new file mode 100644 index 0000000000..6409a1eaf7 --- /dev/null +++ b/src/test/java/racingcar/WinnerCalculatorTest.java @@ -0,0 +1,38 @@ +package racingcar; + +import org.junit.jupiter.api.Test; +import racingcar.domain.Cars; +import racingcar.domain.WinnerCalculator; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class WinnerCalculatorTest { + + @Test + void woni_단독_우승자() { + Cars cars = Cars.fromCsv("pobi, woni, jun"); + + cars.toList().get(0).moveIf(() -> true); + + cars.toList().get(1).moveIf(() -> true); + cars.toList().get(1).moveIf(() -> true); + + cars.toList().get(2).moveIf(() -> false); + + List winners = WinnerCalculator.calculate(cars); + + assertThat(winners).containsExactly("woni"); + } + + @Test + void 공동_우승자() { + Cars cars = Cars.fromCsv("pobi, woni, jun"); + cars.moveAll(() -> true); + cars.moveAll(() -> true); + + List winners = WinnerCalculator.calculate(cars); + assertThat(winners).containsExactlyInAnyOrder("pobi", "woni", "jun"); + } +}