diff --git a/README.md b/README.md index d0286c859f..a4ba16aaac 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ # java-racingcar-precourse + +## 과제 후기 +이번 미션은 솔직히 크게 어렵진 않았는데, 그래도 다른 사람들처럼 클래스를 나누고 역할을 분리해서 구현해봤습니다. +확장성도 좀 고려하면서 구조를 짜봤고 테스트 코드도 직접 활용해보고 싶어서 GPT한테 진행 순서를 만들어 달라고 한 다음 제 스타일에 맞게 수정하면서 진행을 해봤습니다. +그렇게 하다 보니 기능 단위로 커밋하는 흐름이 좀 익숙해졌고 테스트를 통해 동작을 확인하면서 구현하는 게 확실히 편하다는 걸 느낀 미션이었던 거 같습니다~~ + +## 진행 순서 체크리스트 +1. [x] docs(readme): 기능 목록/진행 순서 작성 +2. [x] feat(domain): Car 생성 및 이름 검증(1~5자) +3. [x] test: Car 이름 검증 테스트(빈값/공백/5자 초과) +4. [x] feat(policy): MovePolicy(기본 n≥4) 추가 +5. [x] test: MovePolicy 경계 테스트(3 정지, 4 전진) +6. [x] feat(io): InputView—자동차 이름 입력/파싱/검증 +7. [x] feat(io): InputView—시도 횟수 입력/검증(정수, ≥1) +8. [x] feat(domain): RacingGame—시도 횟수만큼 진행(랜덤 주입) +9. [x] feat(io): OutputView—라운드별 `이름 : ---` 출력 +10. [x] feat(domain): Winners—최대 위치 기반 우승자 목록 계산 +11. [x] feat(io): 최종 우승자 출력(단독/복수) +12. [x] fix: 잘못된 입력 시 IllegalArgumentException 던지고 main에서 메시지 출력 후 종료 +13. [x] test: 통합—기본 시나리오(테스트 제공값 통과: `pobi,woni`/`1`) +14. [x] refactor: 메서드로 분리해 가독성 높이기 + +## 기능 요구 요약 +- 이름(≤5자, 쉼표 구분)과 시도 횟수 입력 +- 0~9 무작위 수가 **4 이상**이면 전진 +- 라운드별 결과 및 최종 우승자 출력(복수 가능) +- 잘못된 입력은 `IllegalArgumentException` 후 종료 + diff --git a/src/main/java/racingcar/Application.java b/src/main/java/racingcar/Application.java index a17a52e724..11f8b61eeb 100644 --- a/src/main/java/racingcar/Application.java +++ b/src/main/java/racingcar/Application.java @@ -1,7 +1,40 @@ package racingcar; +import java.util.List; + public class Application { public static void main(String[] args) { - // TODO: 프로그램 구현 + try { + List cars = InputView.readCars(); + int attempts = InputView.readAttempts(); + + System.out.println(); + System.out.println("실행 결과"); + + RacingGame game = createGame(cars); + play(game, cars, attempts); + printWinners(cars); + } catch (IllegalArgumentException e) { + System.out.println(e.getMessage()); + throw e; + } + } + + private static RacingGame createGame(List cars) { + MovePolicy policy = new DefaultMovePolicy(); + NumberGenerator generator = new MissionUtilsGenerator(); + return new RacingGame(cars, policy, generator); + } + + private static void play(RacingGame game, List cars, int attempts) { + for (int i = 0; i < attempts; i++) { + game.step(); + OutputView.printRound(cars); + } + } + + private static void printWinners(List cars) { + List winners = Winners.of(cars); + OutputView.printWinners(winners); } } diff --git a/src/main/java/racingcar/Car.java b/src/main/java/racingcar/Car.java new file mode 100644 index 0000000000..2b1954041a --- /dev/null +++ b/src/main/java/racingcar/Car.java @@ -0,0 +1,52 @@ +package racingcar; + +import java.util.Objects; + +public final class Car { + private static final int MIN_NAME_LENGTH = 1; + private static final int MAX_NAME_LENGTH = 5; + + private final String name; + private int position = 0; + + public Car(final String name) { + this.name = validateName(name); + } + + private String validateName(final String raw) { + if (raw == null) { + throw new IllegalArgumentException("[ERROR] 자동차 이름은 null일 수 없습니다."); + } + final String trimmed = raw.trim(); + final int len = trimmed.length(); + if (len < MIN_NAME_LENGTH || len > MAX_NAME_LENGTH) { + throw new IllegalArgumentException("[ERROR] 자동차 이름은 1~5자여야 합니다."); + } + return trimmed; + } + + public void moveForward() { + position++; + } + + public String getName() { + return name; + } + + public int getPosition() { + return position; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Car)) return false; + Car car = (Car) o; + return position == car.position && name.equals(car.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, position); + } +} \ No newline at end of file diff --git a/src/main/java/racingcar/DefaultMovePolicy.java b/src/main/java/racingcar/DefaultMovePolicy.java new file mode 100644 index 0000000000..2040ff7fa7 --- /dev/null +++ b/src/main/java/racingcar/DefaultMovePolicy.java @@ -0,0 +1,10 @@ +package racingcar; + +public final class DefaultMovePolicy implements MovePolicy { + private static final int THRESHOLD = 4; + + @Override + public boolean canMove(final int number) { + return number >= THRESHOLD; + } +} diff --git a/src/main/java/racingcar/InputView.java b/src/main/java/racingcar/InputView.java new file mode 100644 index 0000000000..28707e09c3 --- /dev/null +++ b/src/main/java/racingcar/InputView.java @@ -0,0 +1,53 @@ +package racingcar; + +import camp.nextstep.edu.missionutils.Console; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public final class InputView { + private InputView() {} + + public static List readCars() { + System.out.println("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"); + String line = Console.readLine(); + return parseCarNames(line); + } + + // 파싱(검증 포함) + public static List parseCarNames(final String line) { + if (line == null || line.trim().isEmpty()) { + throw new IllegalArgumentException("[ERROR] 자동차 이름을 입력해야 합니다."); + } + String[] tokens = Arrays.stream(line.split(",")) + .map(String::trim) + .toArray(String[]::new); + + List cars = new ArrayList<>(); + for (String name : tokens) { + cars.add(new Car(name)); // Car가 1~5자 검증 + } + if (cars.isEmpty()) { + throw new IllegalArgumentException("[ERROR] 유효한 자동차 이름이 없습니다."); + } + return cars; + } + + // 시도 횟수 읽기/파싱 + public static int readAttempts() { + System.out.println("시도할 횟수는 몇 회인가요?"); + String line = Console.readLine(); + return parseAttempts(line); + } + + public static int parseAttempts(final String line) { + try { + int n = Integer.parseInt(line.trim()); + if (n < 1) throw new NumberFormatException(); + return n; + } catch (Exception e) { + throw new IllegalArgumentException("[ERROR] 시도 횟수는 1 이상의 정수여야 합니다."); + } + } +} diff --git a/src/main/java/racingcar/MissionUtilsGenerator.java b/src/main/java/racingcar/MissionUtilsGenerator.java new file mode 100644 index 0000000000..73065d24d1 --- /dev/null +++ b/src/main/java/racingcar/MissionUtilsGenerator.java @@ -0,0 +1,10 @@ +package racingcar; + +import camp.nextstep.edu.missionutils.Randoms; + +public final class MissionUtilsGenerator implements NumberGenerator { + @Override + public int next0to9() { + return Randoms.pickNumberInRange(0, 9); + } +} diff --git a/src/main/java/racingcar/MovePolicy.java b/src/main/java/racingcar/MovePolicy.java new file mode 100644 index 0000000000..e80d1cf81c --- /dev/null +++ b/src/main/java/racingcar/MovePolicy.java @@ -0,0 +1,5 @@ +package racingcar; + +public interface MovePolicy { + boolean canMove(int number); +} \ No newline at end of file diff --git a/src/main/java/racingcar/NumberGenerator.java b/src/main/java/racingcar/NumberGenerator.java new file mode 100644 index 0000000000..57ca9fbd9c --- /dev/null +++ b/src/main/java/racingcar/NumberGenerator.java @@ -0,0 +1,5 @@ +package racingcar; + +public interface NumberGenerator { + int next0to9(); +} \ No newline at end of file diff --git a/src/main/java/racingcar/OutputView.java b/src/main/java/racingcar/OutputView.java new file mode 100644 index 0000000000..261d4d50da --- /dev/null +++ b/src/main/java/racingcar/OutputView.java @@ -0,0 +1,18 @@ +package racingcar; + +import java.util.List; + +public final class OutputView { + private OutputView() {} + + public static void printRound(final List cars) { + for (Car c : cars) { + System.out.println(c.getName() + " : " + "-".repeat(c.getPosition())); + } + System.out.println(); + } + + public static void printWinners(final List winners) { + System.out.println("최종 우승자 : " + String.join(", ", winners)); + } +} \ No newline at end of file diff --git a/src/main/java/racingcar/RacingGame.java b/src/main/java/racingcar/RacingGame.java new file mode 100644 index 0000000000..4920fa431a --- /dev/null +++ b/src/main/java/racingcar/RacingGame.java @@ -0,0 +1,38 @@ +package racingcar; + +import java.util.List; + +public final class RacingGame { + private final List cars; + private final MovePolicy movePolicy; + private final NumberGenerator generator; + + public RacingGame(final List cars, + final MovePolicy movePolicy, + final NumberGenerator generator) { + this.cars = cars; + this.movePolicy = movePolicy; + this.generator = generator; + } + + // 한 라운드 진행 + public void step() { + for (Car car : cars) { + int n = generator.next0to9(); + if (movePolicy.canMove(n)) { + car.moveForward(); + } + } + } + + // 지정된 횟수 만큼만 진행 + public void run(final int attempts) { + for (int i = 0; i < attempts; i++) { + step(); + } + } + + public List getCars() { + return cars; + } +} diff --git a/src/main/java/racingcar/Winners.java b/src/main/java/racingcar/Winners.java new file mode 100644 index 0000000000..60af6ef9e2 --- /dev/null +++ b/src/main/java/racingcar/Winners.java @@ -0,0 +1,28 @@ +package racingcar; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public final class Winners { + private Winners() {} + + public static List of(final List cars) { + if (cars == null || cars.isEmpty()) { + throw new IllegalArgumentException("[ERROR] 우승자를 계산할 자동차가 없습니다."); + } + + int max = cars.stream() + .max(Comparator.comparingInt(Car::getPosition)) + .map(Car::getPosition) + .orElse(0); + + List winners = new ArrayList<>(); + for (Car car : cars) { + if (car.getPosition() == max) { + winners.add(car.getName()); + } + } + return winners; + } +} diff --git a/src/test/java/racingcar/CarTest.java b/src/test/java/racingcar/CarTest.java new file mode 100644 index 0000000000..1f02b27b5f --- /dev/null +++ b/src/test/java/racingcar/CarTest.java @@ -0,0 +1,43 @@ +package racingcar; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class CarTest { + + @Test + @DisplayName("유효한 이름(1~5자)으로 Car 생성 성공") + void create_with_valid_name() { + Car a = new Car("hsuu"); + Car b = new Car("suu"); + assertThat(a.getName()).isEqualTo("hsuu"); + assertThat(b.getName()).isEqualTo("suu"); + assertThat(a.getPosition()).isZero(); + } + + @Test + @DisplayName("이름이 비거나 공백만인 경우 예외 발생") + void create_with_blank_name_throws() { + assertThatThrownBy(() -> new Car("")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new Car(" ")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("이름이 5자를 초과하면 예외 발생") + void create_with_too_long_name_throws() { + assertThatThrownBy(() -> new Car("hsmygit")) // 7글자 + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("moveForward 호출 시 위치가 1 증가") + void move_forward_increments_position() { + Car car = new Car("pobi"); + car.moveForward(); + assertThat(car.getPosition()).isEqualTo(1); + } +} diff --git a/src/test/java/racingcar/IntegrationTest.java b/src/test/java/racingcar/IntegrationTest.java new file mode 100644 index 0000000000..46ec78ddb2 --- /dev/null +++ b/src/test/java/racingcar/IntegrationTest.java @@ -0,0 +1,49 @@ +package racingcar; + +import camp.nextstep.edu.missionutils.test.NsTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static camp.nextstep.edu.missionutils.test.Assertions.assertRandomNumberInRangeTest; +import static org.assertj.core.api.Assertions.assertThat; + +public class IntegrationTest extends NsTest { + + @Test + @DisplayName("기존에 테스트 코드에 있던 시나리오: 두 차량, 1회 시도, 랜덤 4/3 -> pobi만 전진, pobi 우승") + void provided_like_scenario() { + assertRandomNumberInRangeTest(() -> { + run("pobi,woni", "1"); + String out = output(); + assertThat(out).contains("실행 결과"); + assertThat(out).contains("pobi : -"); + assertThat(out).contains("woni : "); + assertThat(out).contains("최종 우승자 : pobi"); + }, + 4, 3 + ); + } + + @Test + @DisplayName("세 차량, 2회 시도, 동점 우승자 출력 확인") + void three_cars_two_steps_tie_winners() { + // 라운드1: pobi(4->전진), woni(4->전진), jun(3->정지) + // 라운드2: pobi(3->정지), woni(4->전진), jun(4->전진) + assertRandomNumberInRangeTest(() -> { + run("pobi,woni,jun", "2"); + String out = output(); + // 라운드별 출력 존재 여부만 간단 검증 + assertThat(out).contains("pobi : -"); + // 최종: woni(2), pobi(1), jun(1) → 우승자: woni + assertThat(out).contains("최종 우승자 : woni"); + }, + 4, 4, 3, // 라운드1: pobi, woni, jun + 3, 4, 4 // 라운드2: pobi, woni, jun + ); + } + + @Override + public void runMain() { + Application.main(new String[]{}); + } +} diff --git a/src/test/java/racingcar/MovePolicyTest.java b/src/test/java/racingcar/MovePolicyTest.java new file mode 100644 index 0000000000..1224c90679 --- /dev/null +++ b/src/test/java/racingcar/MovePolicyTest.java @@ -0,0 +1,25 @@ +package racingcar; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class MovePolicyTest { + + @Test + @DisplayName("3 이하면 정지") + void stop_when_less_than_threshold() { + MovePolicy policy = new DefaultMovePolicy(); + assertThat(policy.canMove(3)).isFalse(); + assertThat(policy.canMove(0)).isFalse(); + } + + @Test + @DisplayName("4 이상이면 전진") + void move_when_greater_or_equal_threshold() { + MovePolicy policy = new DefaultMovePolicy(); + assertThat(policy.canMove(4)).isTrue(); + assertThat(policy.canMove(9)).isTrue(); + } +}