Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[STEP-2] 로또(자동) #1054

Open
wants to merge 15 commits into
base: 0703kyj
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions lotto/README.md
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기능 목록을 잘 만들어주셨네요 👍
이렇게 작성하신 기능 목록이 결국에는 커밋 단위, 테스트 시나리오까지 이어지게 되는데요,
특히 TDD를 하시는 과정에서 객체 설계 및 “어떤 시나리오를 테스트해야 하지“에 대한 본인만의 답을 만들기에도 도움이 될거예요.

Original file line number Diff line number Diff line change
@@ -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] 총 수익률을 계산할 수 있다.
1 change: 1 addition & 0 deletions lotto/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dependencies {}
25 changes: 25 additions & 0 deletions lotto/src/main/kotlin/lotto/Main.kt
Original file line number Diff line number Diff line change
@@ -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.수익률)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

함수명이나 변수명으로 한글을 활용해주셨네요!
개발자들마다 한글 활용에 대한 여러 가지 시각이 있는 것 같아요.

저는 개인적으로 프로젝트 전체적으로 명확히 한글을 사용하는 상황에 대해 팀에서 합의만 된다면 크게 상관없다고 생각합니다 🙂

}
15 changes: 15 additions & 0 deletions lotto/src/main/kotlin/lotto/domain/CorrectNumbers.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package lotto.domain

data class CorrectNumbers(
val values: Set<Int>,
) {
init {
require(values.size == CORRECT_NUMBER_COUNT) {
"[CorrectNumbers] 당첨 번호의 개수가 6개가 아닙니다. | 당첨번호: '$values'"
Comment on lines +3 to +8
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다른 개발자들도 CorrectNumbers 네이밍을 보고 "로또 당첨 번호"와 관련된 객체라고 이해할 수 있을까요? 어떻게 생각하시나요?

}
}

companion object {
private const val CORRECT_NUMBER_COUNT = 6
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 6이라는 숫자는 LottoCorrectNumbers에서 모두 들고 있는데요,
중복을 의도하신 것일까요? 만약 중복되지 않아야 한다면 어디서 들고 있는 것이 적절하다고 생각하시나요?

}
}
36 changes: 36 additions & 0 deletions lotto/src/main/kotlin/lotto/domain/Lotto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package lotto.domain

import lotto.domain.enums.LottoCompensationStrategy

data class Lotto(
private var correctCount: Int? = null,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코틀린의 가장 큰 매력 중 하나는 null-safety 하다는 점인데요,
correctCount은 꼭 nullable하지 않아도 될 것 같아요.
로또 미션을 진행하는 동안 nullable 타입을 최대한 사용하지 않으며 프로그램을 작성해보면 어떨까요? 🙂

val rolling: () -> Set<Int> = { rollingRandom() },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트 가능한 구조를 잘 설계하기 위한 노력이 보였습니다!

다만 현재 구조에서는 Lotto, Lottos 등을 생성하는 과정에서 실제 정적인 테스트 값이 주입되지 않고 있어서, 실제로는 랜덤 값을 참조하고 있어 개선이 필요해보입니다.

테스트 가능하게 만드는 여러 가지 구조 아이디어가 있으니 다음 글도 도움이 되었으면 좋겠습니다!

) {
val values: Set<Int> = 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<Int> {
return (MIN_LOTTO_NUMBER..MAX_LOTTO_NUMBER)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rollingRandom을 새로 돌릴 때마다 (MIN_LOTTO_NUMBER..MAX_LOTTO_NUMBER) 구문으로 Range 객체가 매번 만들어지게 되는데요, 어떻게 개선할 수 있을까요?

.shuffled()
.take(LOTTO_NUMBER_COUNT)
.toSet()
}
}
}
28 changes: 28 additions & 0 deletions lotto/src/main/kotlin/lotto/domain/LottoPurchaseAmount.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package lotto.domain

data class LottoPurchaseAmount(
val amount: Int,
) {
Comment on lines +3 to +5
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이번 기회에 value class에 대해 살펴보고 data class랑 각각 어떤 상황에서 사용하는 것이 적합할지 직접 판단해보셔도 좋을 것 같아요 !

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
}
}
38 changes: 38 additions & 0 deletions lotto/src/main/kotlin/lotto/domain/LottoUser.kt
Original file line number Diff line number Diff line change
@@ -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<Int>)? = null,
) {
val lotteries: List<Lotto> = 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<LottoCompensationStrategy, Int> {
return LottoCompensationStrategy.entries.associateWith { strategy ->
lotteries.count { it.markedCorrectCount == strategy.correctCount }
}
}

companion object {
private fun generateLotteries(lottoCount: Int, lottoGenerateStrategy: (() -> Set<Int>)?) = List(lottoCount) {
lottoGenerateStrategy?.let { Lotto { it.invoke() } } ?: Lotto()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package lotto.domain.enums

private const val DEFAULT_COMPENSATION_UNIT = "원"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"원"이라는 값은 도메인보다는 뷰 요구사항에 적절하지 않을까요? (자동차 경주 미션 5단계 참고)


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
Comment on lines +19 to +24
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enum class에게도 메세지를 보내어 외부에서 조작하지 않고, 적절한 값을 받아올 수 있는 구조를 잘 만들어주신것 같아요 👍
다만 ~Strategy라는 네이밍을 보면 전략 패턴을 구현했다고 생각할 수 있을 것 같은데요, 혹시 특별한 의도가 있으셨을까요?

}
}
26 changes: 26 additions & 0 deletions lotto/src/main/kotlin/lotto/view/InputView.kt
Original file line number Diff line number Diff line change
@@ -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<Int> {
return this.split(CORRECT_NUMBER_DELIMITER)
.map { it.trim().toIntOrThrow() }
}
}
36 changes: 36 additions & 0 deletions lotto/src/main/kotlin/lotto/view/ResultView.kt
Original file line number Diff line number Diff line change
@@ -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<Lotto>) {
lotteries.forEach {
println(it.values)
}
println()
}

fun printLottoResult(lottoResults: Map<LottoCompensationStrategy, Int>) {
val title = """

당첨 통계
---------
""".trimIndent()
println(title)
lottoResults.forEach { (strategy, count) ->
println("${strategy.correctCount}개 일치 (${strategy.compensation}${strategy.unit})- ${count}개")
}
}

fun print수익률(수익률: BigDecimal) {
println("총 수익률은 ${수익률.setScale(2)}입니다.")
}
}
17 changes: 17 additions & 0 deletions lotto/src/test/kotlin/lotto/domain/CorrectNumbersTest.kt
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> {
CorrectNumbers(inputs)
}
correctNumbers.message shouldContain "당첨 번호의 개수가 6개가 아닙니다"
}
}
54 changes: 54 additions & 0 deletions lotto/src/test/kotlin/lotto/domain/LottoPurchaseAmountTest.kt
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얼마의 금액으로 몇 장의 로또를 살 수 있었는지도 검증해보면 어떨까요~?

@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<IllegalArgumentException> {
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<IllegalArgumentException> {
LottoPurchaseAmount(purchaseAmount)
}
exception.message shouldContain "구매금액은 ${MIN_PURCHASE_AMOUNT}원 단위이어야 합니다"
}
}

companion object {
private const val MIN_PURCHASE_AMOUNT = 1000
}
}
Loading