diff --git a/docs/README.md b/docs/README.md index e69de29bb..6364fe34c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -0,0 +1,32 @@ +# 자동차 경주 게임 + +## 기능 목록 + +- [x] 쉼표를 기준으로 경주할 자동차 이름을 입력 받는다. + - [x] 잘못된 값 입력 시 에러를 발생시킨 후 애플리케이션을 종료한다. + - [x] 입력된 값이 1자 이상 5자 이하이고 영문자인지 알 수 있다. + - [x] 이름 중복 여부를 알 수 있다. +- [x] 몇 번의 이동을 할 것인지(시도할 횟수)를 입력 받는다. + - [x] 잘못된 값 입력 시 에러를 발생시킨 후 애플리케이션을 종료한다. + - [x] 숫자 이외의 값 입력 여부를 알 수 있다. +- [x] 0~9까지의 랜덤한 값을 생성한다. +- [x] 랜덤한 값이 4 이상인지 알 수 있다. +- [x] 자동차를 전진할 수 있다. +- [x] 모든 차수의 실행 결과를 알 수 있도록 자동차의 전진 유무와 상관없이 자동차의 진행 상황을 출력한다. +- [x] 게임 완료 후 우승자를 알 수 있다. + - [x] 우승자가 여러 명일 경우 쉼표를 이용해 구분한다. + +## 기능 요구 사항 + +초간단 자동차 경주 게임을 구현한다. + +주어진 횟수 동안 n대의 자동차는 전진 또는 멈출 수 있다. +각 자동차에 이름을 부여할 수 있다. +전진하는 자동차를 출력할 때 자동차 이름을 같이 출력한다. +자동차 이름은 쉼표(,)를 기준으로 구분하며 이름은 5자 이하만 가능하다. +사용자는 몇 번의 이동을 할 것인지를 입력할 수 있어야 한다. +전진하는 조건은 0에서 9 사이에서 무작위 값을 구한 후 무작위 값이 4 이상일 경우이다. +자동차 경주 게임을 완료한 후 누가 우승했는지를 알려준다. +우승자는 한 명 이상일 수 있다. +우승자가 여러 명일 경우 쉼표(,)를 이용하여 구분한다. +사용자가 잘못된 값을 입력할 경우 IllegalArgumentException을 발생시킨 후 애플리케이션은 종료되어야 한다. diff --git a/src/main/kotlin/racingcar/Application.kt b/src/main/kotlin/racingcar/Application.kt index 0d8f3a79d..8ebc041de 100644 --- a/src/main/kotlin/racingcar/Application.kt +++ b/src/main/kotlin/racingcar/Application.kt @@ -1,5 +1,8 @@ package racingcar +import racingcar.controller.GameController + fun main() { - // TODO: 프로그램 구현 + val controller = GameController() + controller.run() } diff --git a/src/main/kotlin/racingcar/constant/Constants.kt b/src/main/kotlin/racingcar/constant/Constants.kt new file mode 100644 index 000000000..1ecd74a54 --- /dev/null +++ b/src/main/kotlin/racingcar/constant/Constants.kt @@ -0,0 +1,18 @@ +package racingcar.constant + +object Constants { + + const val MIN_NAME_LENGTH = 1 + const val MAX_NAME_LENGTH = 5 + const val MIN_ATTEMPT_COUNT = 0 + + const val START_INCLUSIVE = 0 + const val END_INCLUSIVE = 9 + + const val MIN_FORWARD_THRESHOLD = 4 + + const val CAR_NAME_PROMPT = "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)" + const val ATTEMPTS_PROMPT = "시도할 횟수는 몇 회인가요?" + const val EXECUTION_RESULT_STRING = "실행 결과" + const val FINAL_WINNER_STRING = "최종 우승자 : " +} diff --git a/src/main/kotlin/racingcar/controller/GameController.kt b/src/main/kotlin/racingcar/controller/GameController.kt new file mode 100644 index 000000000..57196c4ef --- /dev/null +++ b/src/main/kotlin/racingcar/controller/GameController.kt @@ -0,0 +1,40 @@ +package racingcar.controller + +import racingcar.constant.Constants.EXECUTION_RESULT_STRING +import racingcar.domain.CarStatusUpdater +import racingcar.domain.NumberGenerator +import racingcar.domain.RoundExecutor +import racingcar.util.Validator +import racingcar.view.InputView +import racingcar.view.RaceView +import racingcar.view.ResultView + +class GameController { + + private val carStatus = mutableMapOf() + + fun run() { + val (carNames, attemptCount) = getUserInput() + println() + Validator(carNames, attemptCount) + play(carNames, attemptCount.toInt()) + printResult() + } + + private fun getUserInput(): Pair, String> { + val input = InputView() + return Pair(input.getCarName(), input.getNumberOfAttemps()) + } + + private fun play(carNames: ArrayList, attemptCount: Int) { + println(EXECUTION_RESULT_STRING) + + val roundExecutor = RoundExecutor() + roundExecutor.executeRounds(carNames, carStatus, attemptCount) + } + + private fun printResult() { + val resultView = ResultView() + resultView.printWinner(carStatus) + } +} diff --git a/src/main/kotlin/racingcar/domain/CarStatusUpdater.kt b/src/main/kotlin/racingcar/domain/CarStatusUpdater.kt new file mode 100644 index 000000000..488c67f8e --- /dev/null +++ b/src/main/kotlin/racingcar/domain/CarStatusUpdater.kt @@ -0,0 +1,11 @@ +package racingcar.domain + +class CarStatusUpdater { + fun moveCarForward(carStatus: MutableMap, carName: String, randomNumber: Int) { + val judgment = Judgment() + + if (judgment.canMoveForward(randomNumber)) { + carStatus[carName] = carStatus[carName] + "-" + } + } +} diff --git a/src/main/kotlin/racingcar/domain/Judgment.kt b/src/main/kotlin/racingcar/domain/Judgment.kt new file mode 100644 index 000000000..440003669 --- /dev/null +++ b/src/main/kotlin/racingcar/domain/Judgment.kt @@ -0,0 +1,9 @@ +package racingcar.domain + +import racingcar.constant.Constants.MIN_FORWARD_THRESHOLD + +class Judgment { + fun canMoveForward(randomNumber: Int): Boolean { + return randomNumber >= MIN_FORWARD_THRESHOLD + } +} diff --git a/src/main/kotlin/racingcar/domain/NumberGenerator.kt b/src/main/kotlin/racingcar/domain/NumberGenerator.kt new file mode 100644 index 000000000..1781ef5a6 --- /dev/null +++ b/src/main/kotlin/racingcar/domain/NumberGenerator.kt @@ -0,0 +1,11 @@ +package racingcar.domain + +import camp.nextstep.edu.missionutils.Randoms +import racingcar.constant.Constants.START_INCLUSIVE +import racingcar.constant.Constants.END_INCLUSIVE + +class NumberGenerator { + fun createRandomNumber(): Int { + return Randoms.pickNumberInRange(START_INCLUSIVE, END_INCLUSIVE) + } +} diff --git a/src/main/kotlin/racingcar/domain/RoundExecutor.kt b/src/main/kotlin/racingcar/domain/RoundExecutor.kt new file mode 100644 index 000000000..a4cb9c4a5 --- /dev/null +++ b/src/main/kotlin/racingcar/domain/RoundExecutor.kt @@ -0,0 +1,25 @@ +package racingcar.domain + +import racingcar.view.RaceView + +class RoundExecutor() { + fun executeRounds(carNames: ArrayList, carStatus: MutableMap, attemptCount: Int) { + val numberGenerator = NumberGenerator() + val carStatusUpdater = CarStatusUpdater() + val raceView = RaceView() + + carStatus.clear() + carStatus.putAll(carNames.associateWith { "" }) + + var currentCount = 0 + var randomNumber: Int + while (currentCount < attemptCount) { + for (car in carStatus) { + randomNumber = numberGenerator.createRandomNumber() + carStatusUpdater.moveCarForward(carStatus, car.key, randomNumber) + } + raceView.printCarProgress(carStatus) + currentCount++ + } + } +} diff --git a/src/main/kotlin/racingcar/util/Validator.kt b/src/main/kotlin/racingcar/util/Validator.kt new file mode 100644 index 000000000..4dd8eba12 --- /dev/null +++ b/src/main/kotlin/racingcar/util/Validator.kt @@ -0,0 +1,41 @@ +package racingcar.util + +import racingcar.constant.Constants.MAX_NAME_LENGTH +import racingcar.constant.Constants.MIN_ATTEMPT_COUNT +import racingcar.constant.Constants.MIN_NAME_LENGTH + +class Validator(carNames: ArrayList, attemptCount: String) { + + init { + isValidLengthAndLetter(carNames) + checkIfDuplicateNameExists(carNames) + isPositiveInteger(attemptCount) + } + + private fun isValidLengthAndLetter(carNames: ArrayList) { + carNames.forEach { carName -> + if (carName.length !in MIN_NAME_LENGTH..MAX_NAME_LENGTH || !carName.all { it.isLetter() }) { + throw IllegalArgumentException("Car names must be alphabetic and have a length of 1 to 5 characters.") + } + } + } + + private fun checkIfDuplicateNameExists(carNames: ArrayList) { + val uniqueNames = carNames.toSet() + if (uniqueNames.size != carNames.size) { + throw IllegalArgumentException("Car names must not be duplicated.") + } + } + + private fun isPositiveInteger(attemptCount: String) { + val attemptCountInt = try { + attemptCount.toInt() + } catch (e: NumberFormatException) { + throw IllegalArgumentException("Attempt count must be a positive integer.") + } + + if (attemptCountInt <= MIN_ATTEMPT_COUNT) { + throw IllegalArgumentException("Attempt count must be a positive integer.") + } + } +} diff --git a/src/main/kotlin/racingcar/view/InputView.kt b/src/main/kotlin/racingcar/view/InputView.kt new file mode 100644 index 000000000..a7f330589 --- /dev/null +++ b/src/main/kotlin/racingcar/view/InputView.kt @@ -0,0 +1,17 @@ +package racingcar.view + +import camp.nextstep.edu.missionutils.Console +import racingcar.constant.Constants.ATTEMPTS_PROMPT +import racingcar.constant.Constants.CAR_NAME_PROMPT + +class InputView { + fun getCarName(): ArrayList { + println(CAR_NAME_PROMPT) + return ArrayList(Console.readLine().split(",").map { it.trim() }) + } + + fun getNumberOfAttemps(): String { + println(ATTEMPTS_PROMPT) + return Console.readLine() + } +} diff --git a/src/main/kotlin/racingcar/view/RaceView.kt b/src/main/kotlin/racingcar/view/RaceView.kt new file mode 100644 index 000000000..d389072b1 --- /dev/null +++ b/src/main/kotlin/racingcar/view/RaceView.kt @@ -0,0 +1,10 @@ +package racingcar.view + +class RaceView { + fun printCarProgress(carStatus: MutableMap) { + for (car in carStatus) { + println(car.key + " : " + car.value) + } + println() + } +} diff --git a/src/main/kotlin/racingcar/view/ResultView.kt b/src/main/kotlin/racingcar/view/ResultView.kt new file mode 100644 index 000000000..95bf2b373 --- /dev/null +++ b/src/main/kotlin/racingcar/view/ResultView.kt @@ -0,0 +1,11 @@ +package racingcar.view + +import racingcar.constant.Constants.FINAL_WINNER_STRING + +class ResultView { + fun printWinner(carStatus: MutableMap) { + val longestDistance = carStatus.values.maxByOrNull { it.length }?.length + val finalWinner = carStatus.filter { it.value.length == longestDistance }.keys + print(FINAL_WINNER_STRING + finalWinner.joinToString(", ")) + } +} diff --git a/src/test/kotlin/racingcar/domain/CarStatusUpdaterTest.kt b/src/test/kotlin/racingcar/domain/CarStatusUpdaterTest.kt new file mode 100644 index 000000000..f2a4a134e --- /dev/null +++ b/src/test/kotlin/racingcar/domain/CarStatusUpdaterTest.kt @@ -0,0 +1,25 @@ +package racingcar.domain + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import racingcar.constant.Constants.MIN_FORWARD_THRESHOLD + +class CarStatusUpdaterTest { + private val carStatusUpdater = CarStatusUpdater() + + @Test + fun `moveCarForward는 난수가 경계값 이상일 때 자동차를 전진`() { + val carStatus = mutableMapOf("CAR" to "") + carStatusUpdater.moveCarForward(carStatus, "CAR", MIN_FORWARD_THRESHOLD) + + assertEquals("-", carStatus["CAR"]) + } + + @Test + fun `moveCarForward는 난수가 경계값 미만일 때 자동차를 정지`() { + val carStatus = mutableMapOf("CAR" to "") + carStatusUpdater.moveCarForward(carStatus, "CAR", MIN_FORWARD_THRESHOLD - 1) + + assertEquals("", carStatus["CAR"]) + } +} diff --git a/src/test/kotlin/racingcar/domain/JudgmentTest.kt b/src/test/kotlin/racingcar/domain/JudgmentTest.kt new file mode 100644 index 000000000..ab5620a42 --- /dev/null +++ b/src/test/kotlin/racingcar/domain/JudgmentTest.kt @@ -0,0 +1,20 @@ +package racingcar.domain + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import racingcar.constant.Constants.MIN_FORWARD_THRESHOLD + +class JudgmentTest { + private val judgment = Judgment() + + @Test + fun `난수가 경계값 이상일 때 전진 가능`() { + assertTrue(judgment.canMoveForward(MIN_FORWARD_THRESHOLD)) + assertTrue(judgment.canMoveForward(MIN_FORWARD_THRESHOLD + 1)) + } + + @Test + fun `난수가 경계값 미만일 때 전진 불가능`() { + assertFalse(judgment.canMoveForward(MIN_FORWARD_THRESHOLD - 1)) + } +} diff --git a/src/test/kotlin/racingcar/domain/NumberGeneratorTest.kt b/src/test/kotlin/racingcar/domain/NumberGeneratorTest.kt new file mode 100644 index 000000000..098b07c4a --- /dev/null +++ b/src/test/kotlin/racingcar/domain/NumberGeneratorTest.kt @@ -0,0 +1,15 @@ +package racingcar.domain + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class NumberGeneratorTest { + @Test + fun `난수가 0부터 9까지의 범위에 있다`() { + val numberGenerator = NumberGenerator() + for (i in 1..100) { + val randomNumber = numberGenerator.createRandomNumber() + assertTrue(randomNumber in 0..9) + } + } +} diff --git a/src/test/kotlin/racingcar/util/ValidatorTest.kt b/src/test/kotlin/racingcar/util/ValidatorTest.kt new file mode 100644 index 000000000..e8a43871b --- /dev/null +++ b/src/test/kotlin/racingcar/util/ValidatorTest.kt @@ -0,0 +1,68 @@ +package racingcar.util + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource + +class ValidatorTest { + + companion object { + @JvmStatic + fun provideCarNames(): List> { + return listOf( + arrayOf("AB##", "ABC"), + arrayOf("A1B2", "ABC"), + arrayOf("A B", "ABC"), + ) + } + } + + @ParameterizedTest + @MethodSource("provideCarNames") + fun `자동차 이름에 문자 이외의 요소가 있을 때 예외 발생`(carName1: String, carName2: String) { + val carNames = arrayListOf(carName1, carName2) + val exception = assertThrows { + Validator(carNames, "5") + } + assertEquals("Car names must be alphabetic and have a length of 1 to 5 characters.", exception.message) + } + + @Test + fun `자동차 이름의 길이가 5보다 클 때 예외 발생`() { + val carNames = arrayListOf("ABCDEF", "ABC") + val exception = assertThrows { + Validator(carNames, "5") + } + assertEquals("Car names must be alphabetic and have a length of 1 to 5 characters.", exception.message) + } + + @Test + fun `자동차 이름이 중복될 때 예외 발생`() { + val carNames = arrayListOf("ABC", "ABC") + val exception = assertThrows { + Validator(carNames, "5") + } + assertEquals("Car names must not be duplicated.", exception.message) + } + + @ParameterizedTest + @ValueSource(strings = ["0", "-1", "숫자가아님"]) + fun `시도할 횟수가 양의 정수가 아닐 때 예외 발생`(count: String) { + val carNames = arrayListOf("ABC", "DEF") + val exception = assertThrows { + Validator(carNames, count) + } + assertEquals("Attempt count must be a positive integer.", exception.message) + } + + @Test + fun `자동차 이름으로 한글을 입력할 때`() { + val carNames = arrayListOf("대한민국", "캐나다") + assertDoesNotThrow { + Validator(carNames, "5") + } + } +} diff --git a/src/test/kotlin/racingcar/view/ResultViewTest.kt b/src/test/kotlin/racingcar/view/ResultViewTest.kt new file mode 100644 index 000000000..918b2436a --- /dev/null +++ b/src/test/kotlin/racingcar/view/ResultViewTest.kt @@ -0,0 +1,43 @@ +package racingcar.view + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.io.PrintStream + +class ResultViewTest { + private val outputStream = ByteArrayOutputStream() + private val printStream = PrintStream(outputStream) + + @BeforeEach + fun setUp() { + System.setOut(printStream) + } + + @AfterEach + fun tearDown() { + System.setOut(null) + } + + @Test + fun `최종 승자가 한 명일 때 승자 출력`() { + val carStatus = mutableMapOf("CAR1" to "---", "CAR2" to "--", "CAR3" to "--") + val resultView = ResultView() + resultView.printWinner(carStatus) + + val expectedOutput = "최종 우승자 : CAR1" + assertEquals(expectedOutput, outputStream.toString().trim()) + } + + @Test + fun `최종 승자가 두 명 이상일 때 모든 승자를 출력`() { + val carStatus = mutableMapOf("CAR1" to "---", "CAR2" to "---", "CAR3" to "--") + val resultView = ResultView() + resultView.printWinner(carStatus) + + val expectedOutput = "최종 우승자 : CAR1, CAR2" + assertEquals(expectedOutput, outputStream.toString().trim()) + } +}