diff --git a/README.md b/README.md index 76c8df1d4..4f92809d3 100644 --- a/README.md +++ b/README.md @@ -1 +1,90 @@ # jwp-racingcar + +# 페어 프로그래밍 룰 + +## 스위치 기준 + +- [x] 스위치 시간은 20분으로 한다. +- [x] 내비게이터는 전자기기에 손을 대지 않는다. + +## 깃 컨벤션 + +- [x] 기능 목록에 있는 기능 단위로 커밋한다. +- [x] 작동할 수 있는 기능 단위로 커밋한다. +- [x] 커밋 메세지는 아래 키워드를 사용해 기능 목록 그대로 작성한다. + - feat: 기능 구현을 완료했을 때 + - refactor: 기능의 변화 없이 코드를 변경했을 때 + - test: 테스트 코드만 작성했을 때 + - chore: 패키지 변경 등 사소한 수정사항이 생겼을 때 + - fix: 프로그램의 결함을 수정할 때 + - docs: 문서를 수정할 때 + +## 코드 컨벤션 + +- [x] 모든 클래스는 `final` 혹은 `abstract` 이어야 한다. +- [x] 모든 파라미터에 `final` 키워드를 붙인다. + +## 구현 계획 + +- [x] 구현은 다음과 같은 순서로 진행된다. + 1. Spring MVC 학습 + 2. 자동차 경주 미션 - 웹 요청/응답 구현하기 + 3. Spring JDBC 학습 + 4. 자동차 경주 미션 - DB 연동하기 + 5. 리팩토링 + +## 기타 룰 + +- [x] 미션 진행 2일차(수) 오후 4:00에 중간 회고를 진행한다. +- [x] 최소한 2시간에 한 번은 쉬어야 한다. +- [x] 커피챗을 최소 1회 진행한다. +- [x] 집중이 안된다면 페어에게 솔직하게 이야기한다. + +--- + +# 추가로 구현할 기능 목록 + +## 웹 애플리케이션 구동 + +- [x] 자동차 경주 진행에 대한 웹 API를 구현한다. + - [x] 자동차 경주 진행에 대한 웹 요청을 받을 수 있다. + - [x] JSON 형태로 입력을 받는다. + - [x] 참여자들의 이름을 입력받는다. + - [x] 시도 횟수를 입력받는다. + - [x] `/plays`로 `POST` 요청을 보낼 시 응답한다. + - [x] 자동차 경주 진행 결과에 대한 웹 응답을 전달할 수 있다. + - [x] JSON 형태로 전달한다. + - [x] 우승자들의 이름을 전달한다. + - [x] 참여자들의 정보를 전달한다. + - [x] 모든 참여자들의 이름을 전달한다. + - [x] 모든 참여자들의 이동 거리를 전달한다. + - [x] 이동 거리의 내림차순으로 정렬 후 전달한다. + - [x] 성공 시 STATUS CODE `200`를 반환한다. + - [x] 실패 시 다음과 같은 STATUS CODE를 반환한다. + - [x] 사용자 입력이 잘못되었을 때는 `400`을 반환한다. + - [x] 정의되지 않은 경로로 요청하는 경우 `404`를 반환한다. + - [x] 정의되지 않은 HTTP 메서드를 호출했을 때는 `405`를 반환한다. + - [x] 서버 내부에서 에러가 발생했을 때는 `500`을 반환한다. + +## DB 연동 + +- [x] 자동차 경주 게임 플레이 이력을 DB에 저장한다. + - [x] H2 Database에 저장된다. + - [x] 저장되는 정보는 다음과 같다. + - [x] 플레이 횟수 + - [x] 플레이어 별 최종 이동 거리(이름, 최종 위치) + - [x] 우승자 + - [x] 플레이한 날짜/시간 + +# TO-STUDY + +- [ ] `@Transactional` 학습, 어떻게 사용할 수 있는지 +- [ ] Mock 테스트의 테스트 범위와 원리 학습하기 +- [ ] RequestBody + Model 같이 사용할 수 있는지 +- [ ] RequestBody의 required가 어떻게 동작하는지 + +# 고민사항 + +- [ ] 이너클래스로 `CarDTO`를 둘지, 외부 객체로 뺼지 +- [ ] 어느 기준으로 DTO를 생성해야 할지 + - [ ] Controller - Service, Service - Dao 사이마다 DTO를 새로 정의해야 하는지(필드가 새롭게 추가되는 경우) diff --git a/build.gradle b/build.gradle index b9473b3bd..d462ec4d3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,20 +1,25 @@ plugins { - id 'java' - id 'org.springframework.boot' version '2.7.9' - id 'io.spring.dependency-management' version '1.0.15.RELEASE' + id 'java' + id 'org.springframework.boot' version '2.7.9' + id 'io.spring.dependency-management' version '1.0.15.RELEASE' } sourceCompatibility = '11' repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' - testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.rest-assured:rest-assured:4.4.0' + + implementation 'org.springframework.boot:spring-boot-starter-jdbc' + runtimeOnly 'com.h2database:h2' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/src/main/java/racingcar/controller/RacingController.java b/src/main/java/racingcar/controller/RacingController.java new file mode 100644 index 000000000..505b68725 --- /dev/null +++ b/src/main/java/racingcar/controller/RacingController.java @@ -0,0 +1,29 @@ +package racingcar.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseBody; +import racingcar.dto.GameResultDto; +import racingcar.dto.RacingGameRequestDto; +import racingcar.service.RacingGameService; + +import javax.validation.Valid; + +@Controller +public final class RacingController { + + private final RacingGameService racingGameService; + + @Autowired + public RacingController(final RacingGameService racingGameService) { + this.racingGameService = racingGameService; + } + + @PostMapping(path = "/plays") + @ResponseBody + public GameResultDto playRacingGame(@Valid @RequestBody final RacingGameRequestDto racingGameRequestDto) { + return racingGameService.playRacingGame(racingGameRequestDto); + } +} diff --git a/src/main/java/racingcar/dao/JdbcTemplateRacingGameDao.java b/src/main/java/racingcar/dao/JdbcTemplateRacingGameDao.java new file mode 100644 index 000000000..4e69304c7 --- /dev/null +++ b/src/main/java/racingcar/dao/JdbcTemplateRacingGameDao.java @@ -0,0 +1,41 @@ +package racingcar.dao; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import racingcar.dto.CarDto; + +import java.sql.PreparedStatement; +import java.util.List; + +@Repository +public class JdbcTemplateRacingGameDao implements RacingGameDao { + + private final JdbcTemplate jdbcTemplate; + + @Autowired + public JdbcTemplateRacingGameDao(final JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public Number saveGameResult(final String winners, final int trialCount) { + final String sqlToInsertGameResult = "INSERT INTO GAME_RESULT (winners, trial_count) values (?, ?)"; + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement preparedStatement = connection.prepareStatement(sqlToInsertGameResult, new String[]{"id"}); + preparedStatement.setString(1, winners); + preparedStatement.setInt(2, trialCount); + return preparedStatement; + }, keyHolder); + return keyHolder.getKey(); + } + + public void savePlayerResults(final List racingCars, final Number gameResultKey) { + for (CarDto carDto : racingCars) { + String sqlToInsertPlayerResult = "INSERT INTO PLAYER_RESULT (name, position, game_result_id) values (?, ?, ?)"; + jdbcTemplate.update(sqlToInsertPlayerResult, carDto.getName(), carDto.getPosition(), gameResultKey); + } + } +} diff --git a/src/main/java/racingcar/dao/RacingGameDao.java b/src/main/java/racingcar/dao/RacingGameDao.java new file mode 100644 index 000000000..88847b4a0 --- /dev/null +++ b/src/main/java/racingcar/dao/RacingGameDao.java @@ -0,0 +1,12 @@ +package racingcar.dao; + +import racingcar.dto.CarDto; + +import java.util.List; + +public interface RacingGameDao { + + Number saveGameResult(final String winners, final int trialCount); + + void savePlayerResults(final List racingCars, final Number gameResultKey); +} diff --git a/src/main/java/racingcar/domain/Car.java b/src/main/java/racingcar/domain/Car.java new file mode 100644 index 000000000..6b0be5563 --- /dev/null +++ b/src/main/java/racingcar/domain/Car.java @@ -0,0 +1,33 @@ +package racingcar.domain; + +public class Car { + + private final CarName carName; + private final Position position; + + public Car(String name, int position) { + this.carName = new CarName(name); + this.position = new Position(position); + } + + public void move() { + position.moveForward(); + } + + public int comparePosition(Car otherCar) { + return this.getPosition() - otherCar.getPosition(); + } + + public boolean hasSamePositionWith(Car otherCar) { + return comparePosition(otherCar) == 0; + } + + public int getPosition() { + return position.getPosition(); + } + + public String getCarName() { + return carName.getCarName(); + } +} + diff --git a/src/main/java/racingcar/domain/CarName.java b/src/main/java/racingcar/domain/CarName.java new file mode 100644 index 000000000..bbbe322d6 --- /dev/null +++ b/src/main/java/racingcar/domain/CarName.java @@ -0,0 +1,37 @@ +package racingcar.domain; + +import racingcar.exception.CarNameBlankException; +import racingcar.exception.CarNameLengthException; + +public class CarName { + + private static final int MAX_CAR_NAME_LENGTH = 5; + + private final String carName; + + public CarName(String carName) { + validateCarName(carName); + this.carName = carName; + } + + private void validateCarName(String carName) { + validateCarNameIsNotEmpty(carName); + validateCarNameLength(carName); + } + + private void validateCarNameIsNotEmpty(String carName) { + if (carName.isBlank()) { + throw new CarNameBlankException(); + } + } + + private void validateCarNameLength(String carName) { + if (carName.length() > MAX_CAR_NAME_LENGTH) { + throw new CarNameLengthException(); + } + } + + public String getCarName() { + return carName; + } +} diff --git a/src/main/java/racingcar/domain/Coin.java b/src/main/java/racingcar/domain/Coin.java new file mode 100644 index 000000000..e89ead14b --- /dev/null +++ b/src/main/java/racingcar/domain/Coin.java @@ -0,0 +1,20 @@ +package racingcar.domain; + +public class Coin { + + private int remaining; + + public Coin(int remaining) { + this.remaining = remaining; + } + + public void use() { + if (isLeft()) { + remaining--; + } + } + + public boolean isLeft() { + return remaining > 0; + } +} diff --git a/src/main/java/racingcar/domain/NumberGenerator.java b/src/main/java/racingcar/domain/NumberGenerator.java new file mode 100644 index 000000000..e258612d3 --- /dev/null +++ b/src/main/java/racingcar/domain/NumberGenerator.java @@ -0,0 +1,6 @@ +package racingcar.domain; + +public interface NumberGenerator { + + int makeDigit(); +} diff --git a/src/main/java/racingcar/domain/Position.java b/src/main/java/racingcar/domain/Position.java new file mode 100644 index 000000000..a3642a80e --- /dev/null +++ b/src/main/java/racingcar/domain/Position.java @@ -0,0 +1,31 @@ +package racingcar.domain; + +import racingcar.exception.PositionInvalidException; + +public class Position { + + private int position; + + public Position(int position) { + validatePosition(position); + this.position = position; + } + + private void validatePosition(int position) { + validatePositionIsNotNegative(position); + } + + private void validatePositionIsNotNegative(int position) { + if (position < 0) { + throw new PositionInvalidException(); + } + } + + public void moveForward() { + position++; + } + + public int getPosition() { + return position; + } +} diff --git a/src/main/java/racingcar/domain/RacingGame.java b/src/main/java/racingcar/domain/RacingGame.java new file mode 100644 index 000000000..a450ebf83 --- /dev/null +++ b/src/main/java/racingcar/domain/RacingGame.java @@ -0,0 +1,61 @@ +package racingcar.domain; + +import racingcar.exception.NoCarsExistException; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class RacingGame { + + private static final int DEFAULT_START_LINE = 0; + private static final int MOVABLE_BOUND = 4; + + private final List cars; + private final NumberGenerator numberGenerator; + private final Coin gameCoin; + + public RacingGame(List splitCarNames, int gameTry, NumberGenerator numberGenerator) { + cars = splitCarNames.stream() + .map(carName -> new Car(carName, DEFAULT_START_LINE)) + .collect(Collectors.toList()); + gameCoin = new Coin(gameTry); + this.numberGenerator = numberGenerator; + } + + public void start() { + for (Car car : cars) { + moveCar(car); + } + gameCoin.use(); + } + + private void moveCar(Car car) { + int randomNumber = numberGenerator.makeDigit(); + if (randomNumber >= MOVABLE_BOUND) { + car.move(); + } + } + + public boolean isGameOnGoing() { + return gameCoin.isLeft(); + } + + public List getCars() { + return Collections.unmodifiableList(cars); + } + + public List getWinners() { + Car furthestCar = getFurthestCar(); + + return cars.stream() + .filter(car -> car.hasSamePositionWith(furthestCar)) + .collect(Collectors.toList()); + } + + private Car getFurthestCar() { + return cars.stream() + .max(Car::comparePosition) + .orElseThrow(NoCarsExistException::new); + } +} diff --git a/src/main/java/racingcar/domain/RandomNumberGenerator.java b/src/main/java/racingcar/domain/RandomNumberGenerator.java new file mode 100644 index 000000000..366b2e44f --- /dev/null +++ b/src/main/java/racingcar/domain/RandomNumberGenerator.java @@ -0,0 +1,13 @@ +package racingcar.domain; + +import java.util.Random; + +public class RandomNumberGenerator implements NumberGenerator { + + private static final int DIGIT_MAX = 10; + + public int makeDigit() { + Random random = new Random(); + return random.nextInt(DIGIT_MAX); + } +} diff --git a/src/main/java/racingcar/dto/CarDto.java b/src/main/java/racingcar/dto/CarDto.java new file mode 100644 index 000000000..c177e9543 --- /dev/null +++ b/src/main/java/racingcar/dto/CarDto.java @@ -0,0 +1,20 @@ +package racingcar.dto; + +public final class CarDto { + + private final String name; + private final int position; + + public CarDto(final String name, final int position) { + this.name = name; + this.position = position; + } + + public String getName() { + return name; + } + + public int getPosition() { + return position; + } +} diff --git a/src/main/java/racingcar/dto/GameResultDto.java b/src/main/java/racingcar/dto/GameResultDto.java new file mode 100644 index 000000000..c5a70b5f6 --- /dev/null +++ b/src/main/java/racingcar/dto/GameResultDto.java @@ -0,0 +1,22 @@ +package racingcar.dto; + +import java.util.List; + +public final class GameResultDto { + + private final String winners; + private final List racingCars; + + public GameResultDto(final String winners, final List racingCars) { + this.winners = winners; + this.racingCars = racingCars; + } + + public String getWinners() { + return winners; + } + + public List getRacingCars() { + return racingCars; + } +} diff --git a/src/main/java/racingcar/dto/RacingGameRequestDto.java b/src/main/java/racingcar/dto/RacingGameRequestDto.java new file mode 100644 index 000000000..28c69361c --- /dev/null +++ b/src/main/java/racingcar/dto/RacingGameRequestDto.java @@ -0,0 +1,25 @@ +package racingcar.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Positive; + +public final class RacingGameRequestDto { + + @NotBlank + private final String names; + @Positive + private final int count; + + public RacingGameRequestDto(final String names, final int count) { + this.names = names; + this.count = count; + } + + public String getNames() { + return names; + } + + public int getCount() { + return count; + } +} diff --git a/src/main/java/racingcar/exception/CarNameBlankException.java b/src/main/java/racingcar/exception/CarNameBlankException.java new file mode 100644 index 000000000..08a8709ca --- /dev/null +++ b/src/main/java/racingcar/exception/CarNameBlankException.java @@ -0,0 +1,10 @@ +package racingcar.exception; + +public class CarNameBlankException extends RuntimeException { + + private static final String MESSAGE = "[ERROR] 자동차의 이름은 공백이면 안됩니다."; + + public CarNameBlankException() { + super(MESSAGE); + } +} diff --git a/src/main/java/racingcar/exception/CarNameLengthException.java b/src/main/java/racingcar/exception/CarNameLengthException.java new file mode 100644 index 000000000..0ac515530 --- /dev/null +++ b/src/main/java/racingcar/exception/CarNameLengthException.java @@ -0,0 +1,10 @@ +package racingcar.exception; + +public class CarNameLengthException extends RuntimeException { + + private static final String MESSAGE = "[ERROR] 자동차 이름의 길이는 1자 이상, 5자 이하여야 합니다."; + + public CarNameLengthException() { + super(MESSAGE); + } +} diff --git a/src/main/java/racingcar/exception/NoCarsExistException.java b/src/main/java/racingcar/exception/NoCarsExistException.java new file mode 100644 index 000000000..51fbbad0a --- /dev/null +++ b/src/main/java/racingcar/exception/NoCarsExistException.java @@ -0,0 +1,10 @@ +package racingcar.exception; + +public class NoCarsExistException extends RuntimeException { + + private final static String MESSAGE = "[ERROR] 자동차가 존재하지 않습니다."; + + public NoCarsExistException() { + super(MESSAGE); + } +} diff --git a/src/main/java/racingcar/exception/PositionInvalidException.java b/src/main/java/racingcar/exception/PositionInvalidException.java new file mode 100644 index 000000000..5f43c3a69 --- /dev/null +++ b/src/main/java/racingcar/exception/PositionInvalidException.java @@ -0,0 +1,14 @@ +package racingcar.exception; + +public class PositionInvalidException extends RuntimeException { + + private final static String MESSAGE = "[ERROR] 자동차의 위치 값은 음수일 수 없습니다."; + + public PositionInvalidException() { + super(MESSAGE); + } + + public String getKoreanMessage() { + return MESSAGE; + } +} diff --git a/src/main/java/racingcar/service/RacingGameService.java b/src/main/java/racingcar/service/RacingGameService.java new file mode 100644 index 000000000..df3f5d57f --- /dev/null +++ b/src/main/java/racingcar/service/RacingGameService.java @@ -0,0 +1,70 @@ +package racingcar.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import racingcar.dao.RacingGameDao; +import racingcar.domain.Car; +import racingcar.domain.RacingGame; +import racingcar.domain.RandomNumberGenerator; +import racingcar.dto.CarDto; +import racingcar.dto.GameResultDto; +import racingcar.dto.RacingGameRequestDto; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public final class RacingGameService { + + private final RacingGameDao racingGameDao; + + @Autowired + public RacingGameService(final RacingGameDao racingGameDao) { + this.racingGameDao = racingGameDao; + } + + public GameResultDto playRacingGame(final RacingGameRequestDto racingGameRequestDto) { + RacingGame racingGame = createRacingGame(racingGameRequestDto); + play(racingGame); + GameResultDto gameResultDto = new GameResultDto( + mapWinnerNamesTextFrom(racingGame), + mapCarDtosFrom(racingGame) + ); + save(gameResultDto, racingGameRequestDto.getCount()); + return gameResultDto; + } + + private RacingGame createRacingGame(final RacingGameRequestDto racingGameRequestDto) { + return new RacingGame( + List.of(racingGameRequestDto.getNames().split(",")), + racingGameRequestDto.getCount(), + new RandomNumberGenerator() + ); + } + + private void play(final RacingGame racingGame) { + // TODO: 검증 로직 도메인 내로 이동 (미션 요구사항에 따라 2단계에서 수정 예정) + while (racingGame.isGameOnGoing()) { + racingGame.start(); + } + } + + private List mapCarDtosFrom(final RacingGame racingGame) { + return racingGame.getCars().stream() + .sorted(Comparator.comparingInt(Car::getPosition).reversed()) + .map(car -> new CarDto(car.getCarName(), car.getPosition())) + .collect(Collectors.toList()); + } + + private String mapWinnerNamesTextFrom(final RacingGame racingGame) { + return racingGame.getWinners().stream() + .map(Car::getCarName) + .collect(Collectors.joining(",")); + } + + private void save(final GameResultDto gameResultDto, final int trialCount) { + Number gameResultKey = racingGameDao.saveGameResult(gameResultDto.getWinners(), trialCount); + racingGameDao.savePlayerResults(gameResultDto.getRacingCars(), gameResultKey); + } +} diff --git a/src/main/java/racingcar/view/input/InputValidator.java b/src/main/java/racingcar/view/input/InputValidator.java new file mode 100644 index 000000000..d0a5d22f3 --- /dev/null +++ b/src/main/java/racingcar/view/input/InputValidator.java @@ -0,0 +1,28 @@ +package racingcar.view.input; + +public class InputValidator { + + private static final int MIN_GAME_TRY_RANGE = 1; + private static final int MAX_GAME_TRY_RANGE = 10000; + + private static final String GAME_TRY_COUNT_OUT_OF_RANGE = "[ERROR] 시도 횟수가 1번 이상 10000번 이하여야 합니다."; + private static final String IS_NOT_INTEGER = "[ERROR] 정수 이외의 다른 문자를 입력해서는 안됩니다."; + + public void validateGameTryRange(String gameTry) { + validateIsInteger(gameTry); + validateGameTryRange(Integer.parseInt(gameTry)); + } + + private void validateIsInteger(String target) { + try { + Integer.parseInt(target); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(IS_NOT_INTEGER); + } + } + private void validateGameTryRange(int gameTry) { + if (gameTry < MIN_GAME_TRY_RANGE || gameTry > MAX_GAME_TRY_RANGE) { + throw new IllegalArgumentException(GAME_TRY_COUNT_OUT_OF_RANGE); + } + } +} diff --git a/src/main/java/racingcar/view/input/InputView.java b/src/main/java/racingcar/view/input/InputView.java new file mode 100644 index 000000000..a25e9b5da --- /dev/null +++ b/src/main/java/racingcar/view/input/InputView.java @@ -0,0 +1,35 @@ +package racingcar.view.input; + +import java.util.List; +import java.util.Scanner; + +public class InputView { + + private static final String CAR_NAMES_INPUT_MESSAGE = "경주할 자동차 이름을 입력하세요(이름은 쉼표(,)를 기준으로 구분)."; + private static final String TRY_COUNT_INPUT_MESSAGE = "시도할 회수는 몇회인가요?"; + + private final Scanner scanner; + private final InputValidator inputValidator = new InputValidator(); + + public InputView(Scanner scanner) { + this.scanner = scanner; + } + + public List readCarName() { + System.out.println(CAR_NAMES_INPUT_MESSAGE); + + final String delimiter = ","; + String carNames = scanner.nextLine(); + + return List.of(carNames.split(delimiter)); + } + + public int readGameTry() { + System.out.println(TRY_COUNT_INPUT_MESSAGE); + + String gameTry = scanner.nextLine(); + + inputValidator.validateGameTryRange(gameTry); + return Integer.parseInt(gameTry); + } +} diff --git a/src/main/java/racingcar/view/output/ConsoleView.java b/src/main/java/racingcar/view/output/ConsoleView.java new file mode 100644 index 000000000..f82f1f82a --- /dev/null +++ b/src/main/java/racingcar/view/output/ConsoleView.java @@ -0,0 +1,49 @@ +package racingcar.view.output; + +import racingcar.domain.Car; + +import java.util.List; +import java.util.StringJoiner; + +public class ConsoleView { + + private static final String RACING_RESULT_MESSAGE = System.lineSeparator() + "실행 결과"; + private static final String RACING_WINNER_MESSAGE = "%s가 최종 우승했습니다." + System.lineSeparator(); + private static final String DISTANCE_MARK = "-"; + private static final String CAR_INFO_DELIMITER = " : "; + private static final String RESULT_DELIMITER = ", "; + + public void printRacingStatus(List cars) { + for (Car car : cars) { + StringJoiner stringJoiner = new StringJoiner(CAR_INFO_DELIMITER); + stringJoiner.add(makeCarNames(car)); + stringJoiner.add(makeCarTrack(car)); + + System.out.println(stringJoiner); + } + System.out.println(); + } + + private String makeCarNames(Car car) { + return car.getCarName(); + } + + private String makeCarTrack(Car car) { + return DISTANCE_MARK.repeat(car.getPosition()); + } + + public void printRacingWinners(List cars) { + StringJoiner stringJoiner = new StringJoiner(RESULT_DELIMITER); + cars.forEach(car -> stringJoiner.add(car.getCarName())); + + System.out.printf(RACING_WINNER_MESSAGE, stringJoiner); + } + + public void printGameResultMessage() { + System.out.println(RACING_RESULT_MESSAGE); + } + + public void printExceptionMessage(String exceptionMessage) { + System.out.println(exceptionMessage); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e69de29bb..119af0059 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -0,0 +1,3 @@ +spring.h2.console.enabled=true +spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL +spring.datasource.driver-class-name=org.h2.Driver diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 56f06adfd..069e792bc 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -1,7 +1,16 @@ --- TODO: 기능 구현에 필요한 내용을 추가하거나 수정하세요. -CREATE TABLE PLAY_RESULT ( +CREATE TABLE GAME_RESULT ( id INT NOT NULL AUTO_INCREMENT, + trial_count INT NOT NULL, winners VARCHAR(50) NOT NULL, created_at DATETIME NOT NULL default current_timestamp, PRIMARY KEY (id) ); + +CREATE TABLE PLAYER_RESULT ( + id INT NOT NULL AUTO_INCREMENT, + name VARCHAR(50) NOT NULL, + position INT NOT NULL, + game_result_id INT NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (game_result_id) REFERENCES GAME_RESULT (`id`) +); diff --git a/src/test/java/racingcar/RacingCarApplicationTests.java b/src/test/java/racingcar/RacingCarApplicationTests.java index e3c70d8de..ce022ceff 100644 --- a/src/test/java/racingcar/RacingCarApplicationTests.java +++ b/src/test/java/racingcar/RacingCarApplicationTests.java @@ -3,11 +3,11 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class RacingCarApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/src/test/java/racingcar/controller/RacingControllerTest.java b/src/test/java/racingcar/controller/RacingControllerTest.java new file mode 100644 index 000000000..f207f7cac --- /dev/null +++ b/src/test/java/racingcar/controller/RacingControllerTest.java @@ -0,0 +1,150 @@ +package racingcar.controller; + +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.ui.ConcurrentModel; +import org.springframework.ui.Model; + +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.core.Is.is; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RacingControllerTest { + + @LocalServerPort + private int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @Nested + @DisplayName("/plays :POST") + class PlaysPost { + + @Test + @DisplayName("GET 응답의 상태코드는 405이다") + void shouldResponse405WhenGetRequest() { + RestAssured.given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().get("/plays") + .then().log().all() + .statusCode(HttpStatus.METHOD_NOT_ALLOWED.value()); + } + + @Test + @DisplayName("필요한 정보를 모두 담아 요청했을 때의 상태 코드는 200이다") + void shouldResponseWhenPostRequest() { + Model model = new ConcurrentModel(); + model.addAttribute("names", "브리,토미,브라운"); + model.addAttribute("count", "3"); + RestAssured.given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(model) + .when().post("/plays") + .then().log().all() + .statusCode(HttpStatus.OK.value()); + } + + @Test + @DisplayName("플레이어들의 이름만 전송했을 때에는 400 예외를 발생한다") + void shouldResponse400WhenRequestWithOnlyPlayerNames() { + Model model = new ConcurrentModel(); + model.addAttribute("names", "브리,토미,브라운"); + RestAssured.given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(model) + .when().post("/plays") + .then().log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("시도 횟수만 전송했을 때에는 400 예외를 발생한다") + void shouldResponse400WhenRequestWithOnlyTryCount() { + Model model = new ConcurrentModel(); + model.addAttribute("count", "3"); + RestAssured.given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(model) + .when().post("/plays") + .then().log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + @DisplayName("플레이어 이름이 빈 문자열이라면 400 예외를 발생한다") + void shouldResponse400WhenRequestWithBlankPlayerNames(final String inputNames) { + Model model = new ConcurrentModel(); + model.addAttribute("names", inputNames); + model.addAttribute("count", "3"); + RestAssured.given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(model) + .when().post("/plays") + .then().log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @ParameterizedTest + @ValueSource(strings = {"0", "-1", "-5"}) + @DisplayName("시도 횟수가 0 이하이면 예외를 발생한다") + void shouldResponse400WhenRequestWithCountBelowZero(final String inputCount) { + Model model = new ConcurrentModel(); + model.addAttribute("names", "브리,토미,브라운"); + model.addAttribute("count", inputCount); + RestAssured.given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(model) + .when().post("/plays") + .then().log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @DisplayName("올바른 요청 시, 우승자와 자동차 정보를 반환한다") + void shouldReturnWinnersAndRacingCarsWhenRequestCorrectly() { + Model model = new ConcurrentModel(); + model.addAttribute("names", "브리,토미,브라운"); + model.addAttribute("count", "3"); + RestAssured.given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(model) + .when().post("/plays") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .body("winners", notNullValue()) + .body("racingCars", notNullValue()) + .body("racingCars.size()", is(3)) + .body("racingCars[0].name", notNullValue()) + .body("racingCars[0].position", notNullValue()) + .body("racingCars[1].name", notNullValue()) + .body("racingCars[1].position", notNullValue()) + .body("racingCars[2].name", notNullValue()) + .body("racingCars[2].position", notNullValue()); + } + } + + @Test + @DisplayName("정의되지 않은 경로에 대한 POST 응답의 상태코드는 404이다") + void shouldResponse404WhenPostRequestToUndefinedPath() { + RestAssured.given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().post("/undefined") + .then().log().all() + .statusCode(HttpStatus.NOT_FOUND.value()); + } +} diff --git a/src/test/java/racingcar/dao/JdbcTemplateRacingGameDaoTest.java b/src/test/java/racingcar/dao/JdbcTemplateRacingGameDaoTest.java new file mode 100644 index 000000000..9c41454e4 --- /dev/null +++ b/src/test/java/racingcar/dao/JdbcTemplateRacingGameDaoTest.java @@ -0,0 +1,83 @@ +package racingcar.dao; + +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.jdbc.core.JdbcTemplate; +import racingcar.dto.CarDto; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class JdbcTemplateRacingGameDaoTest { + + @LocalServerPort + private int port; + @Autowired + private JdbcTemplateRacingGameDao jdbcTemplateRacingGameDao; + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + RestAssured.port = port; + jdbcTemplate.execute("DROP TABLE PLAYER_RESULT IF EXISTS"); + jdbcTemplate.execute("DROP TABLE GAME_RESULT IF EXISTS"); + + jdbcTemplate.execute("CREATE TABLE GAME_RESULT (" + + "id INT NOT NULL PRIMARY KEY AUTO_INCREMENT," + + "trial_count INT NOT NULL," + + "winners VARCHAR(50) NOT NULL," + + "created_at DATETIME NOT NULL default current_timestamp)"); + jdbcTemplate.execute("CREATE TABLE PLAYER_RESULT (" + + "id INT NOT NULL PRIMARY KEY AUTO_INCREMENT," + + "name VARCHAR(50) NOT NULL," + + "position INT NOT NULL," + + "game_result_id INT NOT NULL," + + "FOREIGN KEY (game_result_id) REFERENCES GAME_RESULT (`id`))"); + } + + @Test + @DisplayName("게임 결과가 데이터베이스에 저장될 수 있다") + void shouldSaveGameResultWhenRequest() { + String expectedWinners = "브리,브라운"; + jdbcTemplateRacingGameDao.saveGameResult(expectedWinners, 3); + + String actualWinners = jdbcTemplate.queryForObject("SELECT winners FROM GAME_RESULT", String.class); + int trialCount = jdbcTemplate.queryForObject("SELECT trial_count FROM GAME_RESULT", Integer.class); + assertThat(actualWinners).isEqualTo("브리,브라운"); + assertThat(trialCount).isEqualTo(3); + } + + @Test + @DisplayName("플레이어 별 정보가 데이터베이스에 저장될 수 있다") + void shouldSaveEachPlayerResultWhenRequest() { + // GAME_RESULT 를 저장한다 + String expectedWinners = "브리,브라운"; + Number gameResultKey = jdbcTemplateRacingGameDao.saveGameResult(expectedWinners, 3); + + List carDtos = List.of( + new CarDto("브리", 2), + new CarDto("토미", 1), + new CarDto("브라운", 2) + ); + // 저장한 GAME_RESULT 을 참조하는 PLAYER_RESULT 를 저장한다 + jdbcTemplateRacingGameDao.savePlayerResults(carDtos, gameResultKey); + + int positionOfBri = jdbcTemplate.queryForObject("SELECT position FROM PLAYER_RESULT WHERE name = '브리'", Integer.class); + int positionOfTomi = jdbcTemplate.queryForObject("SELECT position FROM PLAYER_RESULT WHERE name = '토미'", Integer.class); + int positionOfBrown = jdbcTemplate.queryForObject("SELECT position FROM PLAYER_RESULT WHERE name = '브라운'", Integer.class); + assertAll(() -> { + assertThat(positionOfBri).isEqualTo(2); + assertThat(positionOfTomi).isEqualTo(1); + assertThat(positionOfBrown).isEqualTo(2); + }); + } +} diff --git a/src/test/java/racingcar/domain/CarTest.java b/src/test/java/racingcar/domain/CarTest.java new file mode 100644 index 000000000..d6be46e17 --- /dev/null +++ b/src/test/java/racingcar/domain/CarTest.java @@ -0,0 +1,40 @@ +package racingcar.domain; + +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CarTest { + + @ParameterizedTest + @CsvSource(value = {"3,3", "0,0", "213,213"}, delimiter = ',') + void carMoveTest(int moveCount, int expectedPosition) { + Car car = new Car("dummy", 0); + + for (int i = 0; i < moveCount; i++) { + car.move(); + } + + assertThat(car.getPosition()).isEqualTo(expectedPosition); + } + + @ParameterizedTest + @ValueSource(strings = {" ", "", "\n", "abcdef"}) + void validateCarNameWithFailureCaseTest(String carName) { + assertThatThrownBy(() -> new Car(carName, 0)) + .isInstanceOf(RuntimeException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"1", "a", "abc", "abcde"}) + void validateCarNameWithSuccessCaseTest(String carName) { + assertThatCode(() -> new Car(carName, 0)) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/racingcar/domain/DeterminedNumberGenerator.java b/src/test/java/racingcar/domain/DeterminedNumberGenerator.java new file mode 100644 index 000000000..4388e84a2 --- /dev/null +++ b/src/test/java/racingcar/domain/DeterminedNumberGenerator.java @@ -0,0 +1,17 @@ +package racingcar.domain; + +import java.util.List; + +public class DeterminedNumberGenerator implements NumberGenerator { + + private List repository; + private int index = 0; + + public void readRepository(List repository) { + this.repository = repository; + } + + public int makeDigit() { + return repository.get(index++); + } +} diff --git a/src/test/java/racingcar/domain/RacingGameTest.java b/src/test/java/racingcar/domain/RacingGameTest.java new file mode 100644 index 000000000..835228ce7 --- /dev/null +++ b/src/test/java/racingcar/domain/RacingGameTest.java @@ -0,0 +1,43 @@ +package racingcar.domain; + +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class RacingGameTest { + + @ParameterizedTest + @MethodSource("parameterProvider") + void getWinnersTest(List carNames, List determinedNumbers, int gameTry, List expectedWinners) { + DeterminedNumberGenerator determinedNumberGenerator = new DeterminedNumberGenerator(); + RacingGame racingGame = new RacingGame(carNames, gameTry, determinedNumberGenerator); + determinedNumberGenerator.readRepository(determinedNumbers); + + while (racingGame.isGameOnGoing()) { + racingGame.start(); + } + + assertEquals( + racingGame.getWinners().stream().map(Car::getCarName).collect(Collectors.toList()), expectedWinners + ); + } + + private Stream parameterProvider() { + return Stream.of( + Arguments.of(List.of("pobi", "crong"), List.of(5, 3, 9, 0, 0, 9), 3, List.of("pobi")), + Arguments.of(List.of("pobi", "crong"), List.of(3, 5, 9, 0, 0, 9), 3, List.of("crong")), + Arguments.of(List.of("pobi", "crong"), List.of(4, 4, 4, 4, 4, 4), 3, List.of("pobi", "crong")), + Arguments.of(List.of("pobi", "crong"), List.of(0, 0, 0, 0, 0, 0), 3, List.of("pobi", "crong")), + Arguments.of(List.of("pobi", "crong", "hadi"), List.of(3, 4, 9, 6, 7, 6, 0, 1, 2), 3, List.of("crong", "hadi")), + Arguments.of(List.of("pobi", "crong", "hadi"), List.of(7, 4, 9, 6, 7, 6, 0, 1, 2), 3, List.of("pobi", "crong", "hadi")) + ); + } +} diff --git a/src/test/java/racingcar/service/DummyRacingGameDao.java b/src/test/java/racingcar/service/DummyRacingGameDao.java new file mode 100644 index 000000000..861f9d29d --- /dev/null +++ b/src/test/java/racingcar/service/DummyRacingGameDao.java @@ -0,0 +1,17 @@ +package racingcar.service; + +import racingcar.dao.RacingGameDao; +import racingcar.dto.CarDto; + +import java.util.List; + +public class DummyRacingGameDao implements RacingGameDao { + @Override + public Number saveGameResult(final String winners, final int trialCount) { + return null; + } + + @Override + public void savePlayerResults(final List racingCars, final Number gameResultKey) { + } +} diff --git a/src/test/java/racingcar/service/RacingGameServiceTest.java b/src/test/java/racingcar/service/RacingGameServiceTest.java new file mode 100644 index 000000000..6fa328046 --- /dev/null +++ b/src/test/java/racingcar/service/RacingGameServiceTest.java @@ -0,0 +1,42 @@ +package racingcar.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import racingcar.dto.GameResultDto; +import racingcar.dto.RacingGameRequestDto; + +import static org.assertj.core.api.Assertions.assertThat; + + +class RacingGameServiceTest { + + @Test + @DisplayName("플레이어가 한명일 때 GameResultDto가 잘 반환된다") + void shouldReturnCorrectlyWhenInputOnePlayer() { + RacingGameService racingGameService = new RacingGameService(new DummyRacingGameDao()); + RacingGameRequestDto racingGameRequestDto = new RacingGameRequestDto( + "브리", + 3 + ); + + GameResultDto gameResultDto = racingGameService.playRacingGame(racingGameRequestDto); + + assertThat(gameResultDto.getRacingCars()).hasSize(1); + assertThat(gameResultDto.getWinners()).isEqualTo("브리"); + } + + @Test + @DisplayName("플레이어가 여려명일 때 GameResultDto가 잘 반환된다") + void shouldReturnCorrectlyWhenInputManyPlayer() { + RacingGameService racingGameService = new RacingGameService(new DummyRacingGameDao()); + RacingGameRequestDto racingGameRequestDto = new RacingGameRequestDto( + "브리,토미,브라운", + 3 + ); + + GameResultDto gameResultDto = racingGameService.playRacingGame(racingGameRequestDto); + + assertThat(gameResultDto.getRacingCars()).hasSize(3); + assertThat(gameResultDto.getWinners()).isNotNull(); + } +}