diff --git a/.editorconfig b/.editorconfig index bef9fbc308..72dd48ac3b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,3 +11,5 @@ ktlint_standard_multiline-expression-wrapping = disabled ktlint_standard_function-signature = disabled ktlint_standard_filename = disabled ktlint_standard_property-naming = disabled +ktlint_standard_enum-entry-name-case = disabled +ktlint_standard_no-semi = disabled diff --git a/lotto/README.md b/lotto/README.md new file mode 100644 index 0000000000..e7fb69c837 --- /dev/null +++ b/lotto/README.md @@ -0,0 +1,18 @@ +### Lotto + +- [x] 로또 구입 금액을 입력하면 구입 금액에 해당하는 로또를 발급해야 한다. +- [x] 로또 1장의 가격은 1000원이다. + - [x] 최소 구매금액은 1000원이다. + - [x] 구매금액의 최소 단위는 1000원이다. +- [x] 사용자는 지불한 금액만큼의 로또를 가지고 있는다. + - [x] 사용자는 지불한 금액을 알고 있다. +- [x] 각 로또마다 6개의 숫자를 가지고 있다. + - [x] 로또의 숫자들은 무작위로 섞여 있다. + - [x] 로또의 숫자는 1~45 까지 이다. +- [x] 당첨 번호를 입력할 수 있다. + - [x] 당첨 번호에 따른 맞춘 개수를 로또가 가지고 있는다. +- [x] 당첨 통계를 낼 수 있다. + - [x] 3~6개 일치 여부를 판단할 수 있다. + - [x] 일치한 개수 별로 당첨 금액이 다르게 책정된다. + - [x] 당첨된 로또의 개수를 계산할 수 있다. + - [x] 총 수익률을 계산할 수 있다. diff --git a/lotto/build.gradle b/lotto/build.gradle new file mode 100644 index 0000000000..c773c5128a --- /dev/null +++ b/lotto/build.gradle @@ -0,0 +1 @@ +dependencies {} diff --git a/lotto/src/main/kotlin/lotto/Main.kt b/lotto/src/main/kotlin/lotto/Main.kt new file mode 100644 index 0000000000..5eda5e2a29 --- /dev/null +++ b/lotto/src/main/kotlin/lotto/Main.kt @@ -0,0 +1,25 @@ +package lotto + +import lotto.domain.CorrectNumbers +import lotto.domain.LottoPurchaseAmount +import lotto.domain.LottoUser +import lotto.view.InputView.inputCorrectNumbers +import lotto.view.InputView.inputPurchaseAmount +import lotto.view.ResultView.printLotteries +import lotto.view.ResultView.printLottoPurchaseAmount +import lotto.view.ResultView.printLottoResult +import lotto.view.ResultView.print수익률 + +fun main() { + val lottoPurchaseAmount = LottoPurchaseAmount(inputPurchaseAmount()) + printLottoPurchaseAmount(lottoPurchaseAmount) + + val lottoUser = LottoUser(lottoPurchaseAmount) + printLotteries(lottoUser.lotteries) + + val correctNumbers = CorrectNumbers(inputCorrectNumbers()) + + lottoUser.checkLotteries(correctNumbers) + printLottoResult(lottoUser.calculateLottoCorrectCount()) + print수익률(lottoUser.수익률) +} diff --git a/lotto/src/main/kotlin/lotto/domain/CorrectNumbers.kt b/lotto/src/main/kotlin/lotto/domain/CorrectNumbers.kt new file mode 100644 index 0000000000..f953557cf2 --- /dev/null +++ b/lotto/src/main/kotlin/lotto/domain/CorrectNumbers.kt @@ -0,0 +1,15 @@ +package lotto.domain + +data class CorrectNumbers( + val values: Set, +) { + init { + require(values.size == CORRECT_NUMBER_COUNT) { + "[CorrectNumbers] 당첨 번호의 개수가 6개가 아닙니다. | 당첨번호: '$values'" + } + } + + companion object { + private const val CORRECT_NUMBER_COUNT = 6 + } +} diff --git a/lotto/src/main/kotlin/lotto/domain/Lotto.kt b/lotto/src/main/kotlin/lotto/domain/Lotto.kt new file mode 100644 index 0000000000..50b6900c9d --- /dev/null +++ b/lotto/src/main/kotlin/lotto/domain/Lotto.kt @@ -0,0 +1,36 @@ +package lotto.domain + +import lotto.domain.enums.LottoCompensationStrategy + +data class Lotto( + private var correctCount: Int? = null, + val rolling: () -> Set = { rollingRandom() }, +) { + val values: Set = rolling.invoke() + + val markedCorrectCount + get() = correctCount ?: error("[Lotto] 마킹이 되지 않은 로또입니다.") + + val compensation + get() = LottoCompensationStrategy.getCompensationByCorrectCount(correctCount) + + fun markCorrectCount(correctCount: Int) { + if (this.correctCount != null) { + error("[Lotto] 이미 당첨 개수 마킹이 완료된 로또입니다. | 로또 당첨개수: '$this', 마킹 시도한 당첨개수: $correctCount") + } + this.correctCount = correctCount + } + + companion object { + private const val MIN_LOTTO_NUMBER = 1 + private const val MAX_LOTTO_NUMBER = 45 + private const val LOTTO_NUMBER_COUNT = 6 + + private fun rollingRandom(): Set { + return (MIN_LOTTO_NUMBER..MAX_LOTTO_NUMBER) + .shuffled() + .take(LOTTO_NUMBER_COUNT) + .toSet() + } + } +} diff --git a/lotto/src/main/kotlin/lotto/domain/LottoPurchaseAmount.kt b/lotto/src/main/kotlin/lotto/domain/LottoPurchaseAmount.kt new file mode 100644 index 0000000000..24e6de1c1e --- /dev/null +++ b/lotto/src/main/kotlin/lotto/domain/LottoPurchaseAmount.kt @@ -0,0 +1,28 @@ +package lotto.domain + +data class LottoPurchaseAmount( + val amount: Int, +) { + init { + amount.validateMinimum() + amount.validatePurchaseMinUnit() + } + + fun calculateLottoCount(): Int = amount / MIN_PURCHASE_AMOUNT + + private fun Int.validateMinimum() { + require(this >= MIN_PURCHASE_AMOUNT) { + "[LottoPurchaseAmount] 구매금액은 ${MIN_PURCHASE_AMOUNT}원 이상이어야 합니다. | 입력금액: $this" + } + } + + private fun Int.validatePurchaseMinUnit() { + require(this % MIN_PURCHASE_AMOUNT == 0) { + "[LottoPurchaseAmount] 구매금액은 ${MIN_PURCHASE_AMOUNT}원 단위이어야 합니다. | 입력금액: $this" + } + } + + companion object { + private const val MIN_PURCHASE_AMOUNT = 1000 + } +} diff --git a/lotto/src/main/kotlin/lotto/domain/LottoUser.kt b/lotto/src/main/kotlin/lotto/domain/LottoUser.kt new file mode 100644 index 0000000000..6c1d370e4d --- /dev/null +++ b/lotto/src/main/kotlin/lotto/domain/LottoUser.kt @@ -0,0 +1,38 @@ +package lotto.domain + +import lotto.domain.enums.LottoCompensationStrategy +import java.math.BigDecimal + +class LottoUser( + val lottoPurchaseAmount: LottoPurchaseAmount, + lottoCount: Int = lottoPurchaseAmount.calculateLottoCount(), + lottoGenerateStrategy: (() -> Set)? = null, +) { + val lotteries: List = generateLotteries(lottoCount, lottoGenerateStrategy) + + val compensation: Long + get() = lotteries.sumOf { it.compensation } + val 수익률: BigDecimal + get() = BigDecimal(compensation) / BigDecimal(lottoPurchaseAmount.amount) + + fun checkLotteries(correctNumbers: CorrectNumbers) { + lotteries.forEach { lotto -> + val lottoNumbers = lotto.values + val correctCount = lottoNumbers.intersect(correctNumbers.values).size + + lotto.markCorrectCount(correctCount) + } + } + + fun calculateLottoCorrectCount(): Map { + return LottoCompensationStrategy.entries.associateWith { strategy -> + lotteries.count { it.markedCorrectCount == strategy.correctCount } + } + } + + companion object { + private fun generateLotteries(lottoCount: Int, lottoGenerateStrategy: (() -> Set)?) = List(lottoCount) { + lottoGenerateStrategy?.let { Lotto { it.invoke() } } ?: Lotto() + } + } +} diff --git a/lotto/src/main/kotlin/lotto/domain/enums/LottoCompensationStrategy.kt b/lotto/src/main/kotlin/lotto/domain/enums/LottoCompensationStrategy.kt new file mode 100644 index 0000000000..b3ca12c347 --- /dev/null +++ b/lotto/src/main/kotlin/lotto/domain/enums/LottoCompensationStrategy.kt @@ -0,0 +1,26 @@ +package lotto.domain.enums + +private const val DEFAULT_COMPENSATION_UNIT = "원" + +enum class LottoCompensationStrategy( + val correctCount: Int, + val compensation: Long, + val unit: String, +) { + `3개`(3, 5_000, DEFAULT_COMPENSATION_UNIT), + `4개`(4, 50_000, DEFAULT_COMPENSATION_UNIT), + `5개`(5, 1_500_000, DEFAULT_COMPENSATION_UNIT), + `6개`(6, 2_000_000_000, DEFAULT_COMPENSATION_UNIT), + ; + + companion object { + private const val DEFAULT_COMPENSATION = 0L + + fun findByCorrectCount(correctCount: Int?): LottoCompensationStrategy? = + entries.find { it.correctCount == correctCount } + + fun getCompensationByCorrectCount(correctCount: Int?): Long = + findByCorrectCount(correctCount)?.compensation + ?: DEFAULT_COMPENSATION + } +} diff --git a/lotto/src/main/kotlin/lotto/view/InputView.kt b/lotto/src/main/kotlin/lotto/view/InputView.kt new file mode 100644 index 0000000000..0c2bef8b15 --- /dev/null +++ b/lotto/src/main/kotlin/lotto/view/InputView.kt @@ -0,0 +1,26 @@ +package lotto.view + +object InputView { + private const val CORRECT_NUMBER_DELIMITER = "," + + fun inputPurchaseAmount() = input("구입금액을 입력해 주세요.") + .toIntOrThrow() + + fun inputCorrectNumbers() = input("지난 주 당첨 번호를 입력해 주세요.") + .toIntsOrThrow() + .toSet() + + private fun input(message: String): String { + println(message) + return readln() + } + + private fun String.toIntOrThrow(): Int = runCatching { + this.toInt() + }.getOrElse { throw IllegalArgumentException("[InputView] 값을 Int로 변환하는데 실패했습니다. | '$this'") } + + private fun String.toIntsOrThrow(): List { + return this.split(CORRECT_NUMBER_DELIMITER) + .map { it.trim().toIntOrThrow() } + } +} diff --git a/lotto/src/main/kotlin/lotto/view/ResultView.kt b/lotto/src/main/kotlin/lotto/view/ResultView.kt new file mode 100644 index 0000000000..90a426a40b --- /dev/null +++ b/lotto/src/main/kotlin/lotto/view/ResultView.kt @@ -0,0 +1,36 @@ +package lotto.view + +import lotto.domain.Lotto +import lotto.domain.LottoPurchaseAmount +import lotto.domain.enums.LottoCompensationStrategy +import java.math.BigDecimal + +object ResultView { + fun printLottoPurchaseAmount(purchaseAmount: LottoPurchaseAmount) { + val totalLottoCount = purchaseAmount.calculateLottoCount() + println("${totalLottoCount}개를 구매했습니다.") + } + + fun printLotteries(lotteries: List) { + lotteries.forEach { + println(it.values) + } + println() + } + + fun printLottoResult(lottoResults: Map) { + val title = """ + + 당첨 통계 + --------- + """.trimIndent() + println(title) + lottoResults.forEach { (strategy, count) -> + println("${strategy.correctCount}개 일치 (${strategy.compensation}${strategy.unit})- ${count}개") + } + } + + fun print수익률(수익률: BigDecimal) { + println("총 수익률은 ${수익률.setScale(2)}입니다.") + } +} diff --git a/lotto/src/test/kotlin/lotto/domain/CorrectNumbersTest.kt b/lotto/src/test/kotlin/lotto/domain/CorrectNumbersTest.kt new file mode 100644 index 0000000000..dbfdc4f19d --- /dev/null +++ b/lotto/src/test/kotlin/lotto/domain/CorrectNumbersTest.kt @@ -0,0 +1,17 @@ +package lotto.domain + +import io.kotest.assertions.throwables.shouldThrowExactly +import io.kotest.matchers.string.shouldContain +import org.junit.jupiter.api.Test + +class CorrectNumbersTest { + @Test + fun `당첨번호는 6개가 아닌 경우 예외가 발생한다`() { + val inputs = List(5) { it }.toSet() + + val correctNumbers = shouldThrowExactly { + CorrectNumbers(inputs) + } + correctNumbers.message shouldContain "당첨 번호의 개수가 6개가 아닙니다" + } +} diff --git a/lotto/src/test/kotlin/lotto/domain/LottoPurchaseAmountTest.kt b/lotto/src/test/kotlin/lotto/domain/LottoPurchaseAmountTest.kt new file mode 100644 index 0000000000..c1c82a8fec --- /dev/null +++ b/lotto/src/test/kotlin/lotto/domain/LottoPurchaseAmountTest.kt @@ -0,0 +1,54 @@ +package lotto.domain + +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrowExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class LottoPurchaseAmountTest { + @Nested + inner class ValidateTest { + @ParameterizedTest + @ValueSource(ints = [1000, 2000, 3000, 4000]) + fun `로또 1장의 최소 구매금액은 1000원이다`(purchaseAmount: Int) { + val lottoPurchaseAmount = shouldNotThrowAny { + LottoPurchaseAmount(purchaseAmount) + } + lottoPurchaseAmount.amount shouldBe purchaseAmount + } + + @ParameterizedTest + @ValueSource(ints = [200, 400, 600, 800, 999]) + fun `로또를 1000원 미만으로 구매하려는 경우 예외가 발생한다`(purchaseAmount: Int) { + val exception = shouldThrowExactly { + LottoPurchaseAmount(purchaseAmount) + } + exception.message shouldContain "구매금액은 ${MIN_PURCHASE_AMOUNT}원 이상이어야 합니다" + } + + @ParameterizedTest + @ValueSource(ints = [1000, 2000, 3000, 4000]) + fun `로또 1장의 구매금액의 최소 단위는 1000원이다`(purchaseAmount: Int) { + val lottoPurchaseAmount = shouldNotThrowAny { + LottoPurchaseAmount(purchaseAmount) + } + lottoPurchaseAmount.amount shouldBe purchaseAmount + } + + @ParameterizedTest + @ValueSource(ints = [1001, 2010, 3100, 4111]) + fun `로또 1장의 구매금액이 1000원 단위가 아닌 경우 예외가 발생한다`(purchaseAmount: Int) { + val exception = shouldThrowExactly { + LottoPurchaseAmount(purchaseAmount) + } + exception.message shouldContain "구매금액은 ${MIN_PURCHASE_AMOUNT}원 단위이어야 합니다" + } + } + + companion object { + private const val MIN_PURCHASE_AMOUNT = 1000 + } +} diff --git a/lotto/src/test/kotlin/lotto/domain/LottoTest.kt b/lotto/src/test/kotlin/lotto/domain/LottoTest.kt new file mode 100644 index 0000000000..2a3fdb5fb8 --- /dev/null +++ b/lotto/src/test/kotlin/lotto/domain/LottoTest.kt @@ -0,0 +1,94 @@ +package lotto.domain + +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrowExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import lotto.domain.enums.LottoCompensationStrategy +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class LottoTest { + @Test + fun `각 로또마다 6개의 숫자를 가지고 있다`() { + val lotto = Lotto() + + lotto.values.size shouldBe 6 + } + + @Test + fun `로또의 숫자들은 무작위로 섞여 있다`() { + val lotto = Lotto() + + lotto.values.sorted() shouldNotBe lotto.values + } + + @Test + fun `로또의 숫자는 1~45 까지 이다`() { + val lotto = Lotto() + + lotto.values.all { it in 1..45 } shouldBe true + } + + @Test + fun `correctCount가 null이면 마킹할 수 있다`() { + val lotto = Lotto() + + shouldNotThrowAny { + lotto.markCorrectCount(1) + } + } + + @Test + fun `correctCount가 null이면 markedCorrectCount을 조회할 수 없다`() { + val lotto = Lotto() + + val exception = shouldThrowExactly { + lotto.markedCorrectCount + } + exception.message shouldContain "마킹이 되지 않은 로또입니다" + } + + @Test + fun `correctCount가 null이 아닌 경우 예외가 발생한다`() { + val lotto = Lotto() + lotto.markCorrectCount(1) + + val exception = shouldThrowExactly { + lotto.markCorrectCount(1) + } + exception.message shouldContain "이미 당첨 개수 마킹이 완료된 로또입니다" + } + + @Test + fun `correctCount가 null이 아니면 markedCorrectCount을 조회할 수 있다`() { + val lotto = Lotto() + lotto.markCorrectCount(1) + + shouldNotThrowAny { + lotto.markedCorrectCount + } + } + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2]) + fun `맞춘 개수가 3개 미만인 경우 당첨금이 0원이다`(correctCount: Int) { + val lotto = Lotto() + lotto.markCorrectCount(correctCount) + + lotto.markedCorrectCount shouldBe correctCount + lotto.compensation shouldBe 0 + } + + @ParameterizedTest + @ValueSource(ints = [3, 4, 5, 6]) + fun `맞춘 개수가 3개 이상인 경우 당첨금이 존재한다`(correctCount: Int) { + val lotto = Lotto() + lotto.markCorrectCount(correctCount) + + lotto.markedCorrectCount shouldBe correctCount + lotto.compensation shouldBe LottoCompensationStrategy.findByCorrectCount(correctCount)?.compensation + } +} diff --git a/lotto/src/test/kotlin/lotto/domain/LottoUserTest.kt b/lotto/src/test/kotlin/lotto/domain/LottoUserTest.kt new file mode 100644 index 0000000000..be1c53bcd0 --- /dev/null +++ b/lotto/src/test/kotlin/lotto/domain/LottoUserTest.kt @@ -0,0 +1,61 @@ +package lotto.domain + +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.inspectors.shouldForAll +import io.kotest.inspectors.shouldForExactly +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import lotto.domain.enums.LottoCompensationStrategy +import org.junit.jupiter.api.Test + +class LottoUserTest { + @Test + fun `사용자는 구매금액을 가지고 있는다`() { + val lottoPurchaseAmount = LottoPurchaseAmount(1000) + + val lottoUser = LottoUser(lottoPurchaseAmount) + + lottoUser.lottoPurchaseAmount shouldBe lottoPurchaseAmount + } + + @Test + fun `사용자는 지불한 금액만큼의 로또를 가지고 있는다`() { + val lottoPurchaseAmount = LottoPurchaseAmount(1000) + + val lottoUser = LottoUser(lottoPurchaseAmount) + + lottoUser.lotteries.size shouldBe lottoPurchaseAmount.calculateLottoCount() + } + + @Test + fun `사용자는 로또별 당첨금액을 체크할 수 있다`() { + val lottoPurchaseAmount = LottoPurchaseAmount(10000) + val lottoUser = LottoUser(lottoPurchaseAmount) + val inputs = List(6) { it }.toSet() + + val correctNumbers = CorrectNumbers(inputs) + + lottoUser.checkLotteries(correctNumbers) + + lottoUser.lotteries.shouldForAll { + shouldNotThrowAny { + it.markedCorrectCount + } + } + } + + @Test + fun `사용자는 당첨금액을 알고 있다`() { + val lottoPurchaseAmount = LottoPurchaseAmount(10000) + val lottoUser = LottoUser(lottoPurchaseAmount) { + (1..6).toSet() + } + + val inputs = (1..6).toSet() + val correctNumbers = CorrectNumbers(inputs) + + lottoUser.checkLotteries(correctNumbers) + + lottoUser.compensation shouldBe LottoCompensationStrategy.`6개`.compensation * lottoPurchaseAmount.calculateLottoCount() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index b0e6791ad7..aa25adb482 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,3 +3,4 @@ plugins { } rootProject.name = "kotlin-lotto" include("string-calculator") +include("lotto") diff --git a/string-calculator/src/main/kotlin/stringcalculator/StringParser.kt b/string-calculator/src/main/kotlin/stringcalculator/StringParser.kt index dce1d1730c..57b7417fd9 100644 --- a/string-calculator/src/main/kotlin/stringcalculator/StringParser.kt +++ b/string-calculator/src/main/kotlin/stringcalculator/StringParser.kt @@ -3,10 +3,11 @@ package stringcalculator object StringParser { const val 공백 = "" const val 줄바꿈 = "\\n" + private const val DEFAULT_PAYLOAD = "0" - fun String.toCalculateRequest(): StringCalculateRequest { - val payload = this.split(줄바꿈).last() - return StringCalculateRequest(delimiter = Delimiter(this), payload = payload) + fun String?.toCalculateRequest(): StringCalculateRequest { + val payload = this?.split(줄바꿈)?.last() + return StringCalculateRequest(delimiter = Delimiter(this), payload = payload ?: DEFAULT_PAYLOAD) } fun String.splitToInts(delimiter: Delimiter): List { diff --git a/string-calculator/src/test/kotlin/stringcalculator/StringParserTest.kt b/string-calculator/src/test/kotlin/stringcalculator/StringParserTest.kt index 9feba95ba3..34614d5935 100644 --- a/string-calculator/src/test/kotlin/stringcalculator/StringParserTest.kt +++ b/string-calculator/src/test/kotlin/stringcalculator/StringParserTest.kt @@ -4,6 +4,8 @@ import io.kotest.assertions.throwables.shouldThrowExactly import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.NullAndEmptySource import stringcalculator.StringParser.splitToInts import stringcalculator.StringParser.toCalculateRequest @@ -75,4 +77,13 @@ class StringParserTest { exception.message shouldContain "값이 음수거나 Int로 변환하는데 실패했습니다" } + + @ParameterizedTest + @NullAndEmptySource + fun `빈 문자열 또는 null 값을 입력할 경우 0을 반환해야 한다`(input: String?) { + val (delimiter, payload) = input.toCalculateRequest() + val numbers = payload.splitToInts(delimiter) + + numbers.sum() shouldBe 0 + } }