diff --git a/README.md b/README.md index 756bea080e..89f002753a 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,35 @@ - num1, num2 => + 연산 - ExpressionEvaluator : 연산 대상 (숫자)목록을 속성으로 가지고 있고, operator 를 호출하여 **연산된 결과를 반환** 담당 - 연산은 생성자로 전달받을 Operator 로 진행 + +### 2단계 기능목록 +- 로또 구입 금액을 입력 받는다 +- 입력 받은 금액으로 구매할 수 있는 로또 개수를 계산한다 +- 로또 개수만큼 로또를 발행한다 +- 로또는 숫자 6개를 중복 없이 추출한다 +- 발행한 로또는 오름차순으로 정렬한다 +- 지난 주 당첨 번호를 입력받는다 +- 지난 주 당첨 번호를 기준으로 발행한 로또에서 당첨 개수를 계산한다 +- 총 수익률을 계산한다 + + +[ Lotto ] +- LottoMachine + - LottoNumbers + - 6개 로또 번호 생성 + - Keyboard + - 구입금액 입력 + - 지난 주 당첨 번호 입력 + - MachineProcess + - LottoPrice + - 로또 구매 가능 개수 계산 + - LottoRank + - 당첨 개수 계산 + - LottoStatistics + - 총 당첨금액 계산 + - 총 수익률 계산 + - LottoMonitor + - 로또 구매 개수 출력 + - 구매한 로또 출력 + - 당첨 통계 출력 + - 총 수익률 출력 \ No newline at end of file diff --git a/src/main/kotlin/calculator/model/input/DelimiterAndNumbers.kt b/src/main/kotlin/calculator/model/input/DelimiterAndNumbers.kt new file mode 100644 index 0000000000..511e4a2877 --- /dev/null +++ b/src/main/kotlin/calculator/model/input/DelimiterAndNumbers.kt @@ -0,0 +1,6 @@ +package calculator.model.input + +data class DelimiterAndNumbers( + val delimiter: String?, + val numbers: String, +) diff --git a/src/main/kotlin/calculator/model/input/InputParser.kt b/src/main/kotlin/calculator/model/input/InputParser.kt index 1cb7471d91..d031bf32c4 100644 --- a/src/main/kotlin/calculator/model/input/InputParser.kt +++ b/src/main/kotlin/calculator/model/input/InputParser.kt @@ -1,7 +1,14 @@ package calculator.model.input object InputParser { + const val NON_NUMERIC_DEFAULT_VALUE = -1 private val DEFAULT_DELIMITERS = arrayOf(",", ":") + private val customDelimiterRegex: Regex by lazy { + Regex("//(.)\n(.*)") + } + private val nonNumericCharacterRegex: Regex by lazy { + Regex("-?\\d+") + } fun parse(input: String?): List { if (input.isNullOrEmpty()) return listOf(0) @@ -15,14 +22,14 @@ object InputParser { return numbers } - private fun extractDelimiterAndNumbers(input: String): Pair { - val result = Regex("//(.)\n(.*)").find(input) + private fun extractDelimiterAndNumbers(input: String): DelimiterAndNumbers { + val result = customDelimiterRegex.find(input) return if (result != null) { val customDelimiter = result.groupValues[1] val numbers = result.groupValues[2] - customDelimiter to numbers + DelimiterAndNumbers(customDelimiter, numbers) } else { - null to input + DelimiterAndNumbers(null, input) } } @@ -41,12 +48,12 @@ object InputParser { private fun parseNonNegativeNumbers(tokens: List): List { return tokens.map { token -> - token.toIntOrNull() ?: -1 + token.toIntOrNull() ?: NON_NUMERIC_DEFAULT_VALUE } } private fun extractNonNumericCharacters(input: String): List { - return input.replace(Regex("-?\\d+"), "").trim() + return input.replace(nonNumericCharacterRegex, "").trim() .map { "$it" } } } diff --git a/src/main/kotlin/calculator/model/input/InputValidator.kt b/src/main/kotlin/calculator/model/input/InputValidator.kt index 0e5e59efea..62fce6b236 100644 --- a/src/main/kotlin/calculator/model/input/InputValidator.kt +++ b/src/main/kotlin/calculator/model/input/InputValidator.kt @@ -3,7 +3,7 @@ package calculator.model.input object InputValidator { fun validateNumbers(numbers: List) { numbers.forEach { number -> - if (number < 0) throw IllegalArgumentException("음수는 계산할 수 없습니다.") + if (number == InputParser.NON_NUMERIC_DEFAULT_VALUE) throw IllegalArgumentException("음수는 계산할 수 없습니다.") } } diff --git a/src/main/kotlin/lotto/LottoApplication.kt b/src/main/kotlin/lotto/LottoApplication.kt new file mode 100644 index 0000000000..5cb9b2bf5c --- /dev/null +++ b/src/main/kotlin/lotto/LottoApplication.kt @@ -0,0 +1,17 @@ +package lotto + +import lotto.model.price.Lotto2024Price +import lotto.model.process.LottoMachineProcess +import lotto.model.rank.LottoWinningRank +import lotto.view.keyboard.MachineKeyboard +import lotto.view.monitor.LottoMonitor + +fun main() { + val price = Lotto2024Price() + val lottoRank = LottoWinningRank() + val process = LottoMachineProcess(price, lottoRank) + val keyboard = MachineKeyboard() + val monitor = LottoMonitor() + val machine = LottoMachine(process, keyboard, monitor) + machine.issuanceLottoNumber() +} diff --git a/src/main/kotlin/lotto/LottoMachine.kt b/src/main/kotlin/lotto/LottoMachine.kt new file mode 100644 index 0000000000..26a60b632a --- /dev/null +++ b/src/main/kotlin/lotto/LottoMachine.kt @@ -0,0 +1,31 @@ +package lotto + +import lotto.model.process.MachineProcess +import lotto.view.keyboard.Keyboard +import lotto.view.monitor.Monitor + +class LottoMachine( + private val process: MachineProcess, + private val keyboard: Keyboard, + private val monitor: Monitor, +) : Machine { + override fun issuanceLottoNumber() { + monitor.displayLottoPurchaseAmount() + val totalPurchaseAmount = keyboard.inputLottoPrice() + val lottoCount = process.calculateLottoCount(totalPurchaseAmount) + monitor.displayLottoPurchasesCount(lottoCount) + + val lottoTickets = process.generateLottoTickets(lottoCount) + monitor.displayIssuedLottoTickets(lottoTickets) + + monitor.displayInputLastWeekLottoWinningNumbers() + val lastWeekNumbers = keyboard.inputLastWeekWinningNumbers() + val lottoStatistics = + process.calculateWinningStatistics( + lottoTickets, + lastWeekNumbers, + totalPurchaseAmount, + ) + monitor.displayLottoStatistics(lottoStatistics) + } +} diff --git a/src/main/kotlin/lotto/Machine.kt b/src/main/kotlin/lotto/Machine.kt new file mode 100644 index 0000000000..ffcfc7f478 --- /dev/null +++ b/src/main/kotlin/lotto/Machine.kt @@ -0,0 +1,5 @@ +package lotto + +interface Machine { + fun issuanceLottoNumber() +} diff --git a/src/main/kotlin/lotto/model/number/LottoNumbers.kt b/src/main/kotlin/lotto/model/number/LottoNumbers.kt new file mode 100644 index 0000000000..8682920bce --- /dev/null +++ b/src/main/kotlin/lotto/model/number/LottoNumbers.kt @@ -0,0 +1,44 @@ +package lotto.model.number + +class LottoNumbers( + private val lottoNumbers: List, +) : List by lottoNumbers { + init { + require(lottoNumbers.size == DEFAULT_LOTTO_COUNT) { + "로또 번호는 ${DEFAULT_LOTTO_COUNT}개여야 합니다." + } + require(lottoNumbers.all { it in LOTTO_MIN_NUMBER..LOTTO_MAX_NUMBER }) { + "로또 번호는 $LOTTO_MIN_NUMBER ~ $LOTTO_MAX_NUMBER 사이여야 합니다." + } + require(lottoNumbers.distinct().size == lottoNumbers.size) { + "로또 번호는 중복될 수 없습니다." + } + } + + override fun toString(): String { + return "$lottoNumbers" + } + + companion object { + const val LOTTO_MIN_NUMBER = 1 + const val LOTTO_MAX_NUMBER = 45 + const val DEFAULT_LOTTO_COUNT = 6 + private val NUMBERS = (LOTTO_MIN_NUMBER..LOTTO_MAX_NUMBER) + + fun issuanceLottoNumbers(lottoCount: Int = DEFAULT_LOTTO_COUNT): LottoNumbers { + val lottoNumbers = + NUMBERS.shuffled().take(lottoCount) + .sorted() + return LottoNumbers(lottoNumbers) + } + + fun issuanceLottoTickets( + ticketCount: Int, + lottoCount: Int = DEFAULT_LOTTO_COUNT, + ): List { + return List(ticketCount) { + issuanceLottoNumbers(lottoCount) + } + } + } +} diff --git a/src/main/kotlin/lotto/model/price/Lotto2024Price.kt b/src/main/kotlin/lotto/model/price/Lotto2024Price.kt new file mode 100644 index 0000000000..d4cef2e236 --- /dev/null +++ b/src/main/kotlin/lotto/model/price/Lotto2024Price.kt @@ -0,0 +1,15 @@ +package lotto.model.price + +class Lotto2024Price( + override val price: Int = 1000, +) : LottoPrice { + init { + require(price > 0) { + "로또 금액 설정이 잘못되었습니다" + } + } + + override fun calculateLottoCount(purchaseAmount: Int): Int { + return purchaseAmount.div(price) + } +} diff --git a/src/main/kotlin/lotto/model/price/LottoPrice.kt b/src/main/kotlin/lotto/model/price/LottoPrice.kt new file mode 100644 index 0000000000..21dcc50f61 --- /dev/null +++ b/src/main/kotlin/lotto/model/price/LottoPrice.kt @@ -0,0 +1,7 @@ +package lotto.model.price + +interface LottoPrice { + val price: Int + + fun calculateLottoCount(purchaseAmount: Int): Int +} diff --git a/src/main/kotlin/lotto/model/process/LottoMachineProcess.kt b/src/main/kotlin/lotto/model/process/LottoMachineProcess.kt new file mode 100644 index 0000000000..e5885b3795 --- /dev/null +++ b/src/main/kotlin/lotto/model/process/LottoMachineProcess.kt @@ -0,0 +1,33 @@ +package lotto.model.process + +import lotto.model.number.LottoNumbers +import lotto.model.price.LottoPrice +import lotto.model.rank.LottoRank +import lotto.model.statistics.DefaultLottoStatistics +import lotto.model.statistics.LottoStatistics + +class LottoMachineProcess( + private val lottoPrice: LottoPrice, + private val lottoRank: LottoRank, +) : MachineProcess { + override fun calculateLottoCount(purchaseAmount: Int): Int { + return lottoPrice.calculateLottoCount(purchaseAmount) + } + + override fun generateLottoTickets(lottoCount: Int): List { + return LottoNumbers.issuanceLottoTickets(lottoCount) + } + + override fun calculateWinningStatistics( + lottoTickets: List, + winningNumbers: List, + totalPurchaseAmount: Int, + ): LottoStatistics { + val winningRank = + lottoRank.calculateWinningCounts( + lottoTickets, + winningNumbers, + ) + return DefaultLottoStatistics(winningRank, totalPurchaseAmount) + } +} diff --git a/src/main/kotlin/lotto/model/process/MachineProcess.kt b/src/main/kotlin/lotto/model/process/MachineProcess.kt new file mode 100644 index 0000000000..950eddfe73 --- /dev/null +++ b/src/main/kotlin/lotto/model/process/MachineProcess.kt @@ -0,0 +1,16 @@ +package lotto.model.process + +import lotto.model.number.LottoNumbers +import lotto.model.statistics.LottoStatistics + +interface MachineProcess { + fun calculateLottoCount(purchaseAmount: Int): Int + + fun generateLottoTickets(lottoCount: Int): List + + fun calculateWinningStatistics( + lottoTickets: List, + winningNumbers: List, + totalPurchaseAmount: Int, + ): LottoStatistics +} diff --git a/src/main/kotlin/lotto/model/rank/LottoRank.kt b/src/main/kotlin/lotto/model/rank/LottoRank.kt new file mode 100644 index 0000000000..9318e05baa --- /dev/null +++ b/src/main/kotlin/lotto/model/rank/LottoRank.kt @@ -0,0 +1,10 @@ +package lotto.model.rank + +import lotto.model.number.LottoNumbers + +interface LottoRank { + fun calculateWinningCounts( + lottoTickets: List, + winningNumbers: List, + ): Map +} diff --git a/src/main/kotlin/lotto/model/rank/LottoWinningRank.kt b/src/main/kotlin/lotto/model/rank/LottoWinningRank.kt new file mode 100644 index 0000000000..5c8e41d668 --- /dev/null +++ b/src/main/kotlin/lotto/model/rank/LottoWinningRank.kt @@ -0,0 +1,33 @@ +package lotto.model.rank + +import lotto.model.number.LottoNumbers + +class LottoWinningRank : LottoRank { + override fun calculateWinningCounts( + lottoTickets: List, + winningNumbers: List, + ): Map { + return lottoTickets.groupingBy { lottoNumbers -> + val winningCounts = matchWinningLottoNumbersCount(lottoNumbers, winningNumbers) + winningCounts + }.eachCount().filter { it.key >= 3 } + } + + private fun matchWinningLottoNumbersCount( + lottoNumbers: LottoNumbers, + winningNumbers: List, + ): Int { + val winningSets = winningNumbers.toSet() + return lottoNumbers.count(winningSets::contains) + } + + companion object { + val DEFAULT_RANK_PRICE = + mapOf( + 6 to 2_000_000_000, + 5 to 1_500_000, + 4 to 50_000, + 3 to 5_000, + ) + } +} diff --git a/src/main/kotlin/lotto/model/statistics/DefaultLottoStatistics.kt b/src/main/kotlin/lotto/model/statistics/DefaultLottoStatistics.kt new file mode 100644 index 0000000000..1cedc4b058 --- /dev/null +++ b/src/main/kotlin/lotto/model/statistics/DefaultLottoStatistics.kt @@ -0,0 +1,29 @@ +package lotto.model.statistics + +import lotto.model.rank.LottoWinningRank + +class DefaultLottoStatistics( + override val winningRank: Map, + private val totalPurchaseAmount: Int, +) : LottoStatistics { + override val profitRate: Double + + init { + profitRate = calculateProfitRate() + } + + private fun calculateProfitRate(): Double { + val totalPrize = calculateTotalPrize() + return if (totalPrize > 0) { + totalPrize.toDouble().div(totalPurchaseAmount) + } else { + 0.0 + } + } + + private fun calculateTotalPrize(): Long { + return winningRank.map { (key, count) -> + (LottoWinningRank.DEFAULT_RANK_PRICE[key]?.toLong() ?: 0).times(count) + }.sum() + } +} diff --git a/src/main/kotlin/lotto/model/statistics/LottoStatistics.kt b/src/main/kotlin/lotto/model/statistics/LottoStatistics.kt new file mode 100644 index 0000000000..a81fdd1a8a --- /dev/null +++ b/src/main/kotlin/lotto/model/statistics/LottoStatistics.kt @@ -0,0 +1,6 @@ +package lotto.model.statistics + +interface LottoStatistics { + val winningRank: Map + val profitRate: Double +} diff --git a/src/main/kotlin/lotto/view/keyboard/Keyboard.kt b/src/main/kotlin/lotto/view/keyboard/Keyboard.kt new file mode 100644 index 0000000000..ad2646f6af --- /dev/null +++ b/src/main/kotlin/lotto/view/keyboard/Keyboard.kt @@ -0,0 +1,7 @@ +package lotto.view.keyboard + +interface Keyboard { + fun inputLottoPrice(): Int + + fun inputLastWeekWinningNumbers(): List +} diff --git a/src/main/kotlin/lotto/view/keyboard/MachineKeyboard.kt b/src/main/kotlin/lotto/view/keyboard/MachineKeyboard.kt new file mode 100644 index 0000000000..9c11e83a65 --- /dev/null +++ b/src/main/kotlin/lotto/view/keyboard/MachineKeyboard.kt @@ -0,0 +1,26 @@ +package lotto.view.keyboard + +class MachineKeyboard : Keyboard { + override fun inputLottoPrice(): Int { + val input = readlnOrNull()?.toIntOrNull() + require(input != null && input > 0) { "구입금액이 잘못 입력되었습니다" } + return input + } + + override fun inputLastWeekWinningNumbers(): List { + val input = readlnOrNull() + require(!input.isNullOrEmpty()) { "당첨 번호 입력이 잘못되었습니다." } + return parseLottoNumbers(input) + } + + private fun parseLottoNumbers(input: String): List { + return input.split(",") + .map(::validateAndParseNumber) + } + + private fun validateAndParseNumber(numberString: String): Int { + val number = numberString.trim().toIntOrNull() + require(number != null) { "당첨 번호 입력이 잘못되었습니다." } + return number + } +} diff --git a/src/main/kotlin/lotto/view/monitor/LottoMonitor.kt b/src/main/kotlin/lotto/view/monitor/LottoMonitor.kt new file mode 100644 index 0000000000..c14d532c44 --- /dev/null +++ b/src/main/kotlin/lotto/view/monitor/LottoMonitor.kt @@ -0,0 +1,57 @@ +package lotto.view.monitor + +import lotto.model.number.LottoNumbers +import lotto.model.rank.LottoWinningRank +import lotto.model.statistics.LottoStatistics +import java.math.BigDecimal +import java.math.RoundingMode + +class LottoMonitor : Monitor { + override fun displayLottoPurchaseAmount() { + println("구입금액을 입력해 주세요.") + } + + override fun displayLottoPurchasesCount(count: Int) { + println("${count}개를 구매했습니다.") + } + + override fun displayInputLastWeekLottoWinningNumbers() { + println("지난 주 당첨 번호를 입력해 주세요.") + } + + override fun displayIssuedLottoTickets(lottoTickets: List) { + lottoTickets.forEach { lottoNumbers -> + println(lottoNumbers) + } + println() + } + + override fun displayLottoStatistics(statistics: LottoStatistics) { + println() + println("당첨 통계") + println("---------") + displayRankStatistics(statistics.winningRank) + displayProfitRate(statistics.profitRate) + } + + private fun displayRankStatistics(winningRank: Map) { + (3..6).forEach { rank -> + val count = winningRank[rank] ?: 0 + val prize = LottoWinningRank.DEFAULT_RANK_PRICE[rank] ?: 0 + println("${rank}개 일치 (${prize}원)- ${count}개") + } + } + + private fun displayProfitRate(profitRate: Double) { + val rate = + BigDecimal(profitRate) + .setScale(2, RoundingMode.DOWN) + .toDouble() + + if (profitRate > 1.0) { + println("총 수익률은 ${rate}입니다.") + } else { + println("총 수익률은 ${rate}입니다.(기준이 1이기 때문에 결과적으로 손해라는 의미임)") + } + } +} diff --git a/src/main/kotlin/lotto/view/monitor/Monitor.kt b/src/main/kotlin/lotto/view/monitor/Monitor.kt new file mode 100644 index 0000000000..0e35415750 --- /dev/null +++ b/src/main/kotlin/lotto/view/monitor/Monitor.kt @@ -0,0 +1,16 @@ +package lotto.view.monitor + +import lotto.model.number.LottoNumbers +import lotto.model.statistics.LottoStatistics + +interface Monitor { + fun displayLottoPurchaseAmount() + + fun displayLottoPurchasesCount(count: Int) + + fun displayInputLastWeekLottoWinningNumbers() + + fun displayIssuedLottoTickets(lottoTickets: List) + + fun displayLottoStatistics(statistics: LottoStatistics) +} diff --git a/src/test/kotlin/lotto/model/number/LottoNumbersTest.kt b/src/test/kotlin/lotto/model/number/LottoNumbersTest.kt new file mode 100644 index 0000000000..467dc117c9 --- /dev/null +++ b/src/test/kotlin/lotto/model/number/LottoNumbersTest.kt @@ -0,0 +1,92 @@ +package lotto.model.number + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class LottoNumbersTest { + @Test + fun `로또 번호는 6개여야 한다`() { + val lottoNumbers = LottoNumbers.issuanceLottoNumbers() + lottoNumbers.size shouldBe 6 + } + + @Test + fun `로또 번호는 1 ~ 45 사이여야 한다`() { + repeat(100) { + val lottoNumbers = LottoNumbers.issuanceLottoNumbers() + val lottoNumberRange = LottoNumbers.LOTTO_MIN_NUMBER..LottoNumbers.LOTTO_MAX_NUMBER + val result = + lottoNumbers.all { + it in lottoNumberRange + } + result shouldBe true + } + } + + @Test + fun `로또 번호 중복 없이 발행`() { + val lottoNumbers = LottoNumbers.issuanceLottoNumbers() + val count = lottoNumbers.distinct().count() + count shouldBe LottoNumbers.DEFAULT_LOTTO_COUNT + } + + @ParameterizedTest + @ValueSource( + strings = [ + "1,2,3", + "1", + "1,2,3,4,5,6,7", + ], + ) + fun `로또 번호는 6개여야 한다2`(rawNumbers: String) { + val numbers = + rawNumbers.split(",") + .map { it.toInt() } + shouldThrow { + LottoNumbers(numbers) + }.apply { + message shouldBe "로또 번호는 ${LottoNumbers.DEFAULT_LOTTO_COUNT}개여야 합니다." + } + } + + @ParameterizedTest + @ValueSource( + strings = [ + "1,2,3,4,5,46", + "0,1,2,3,4,5", + ], + ) + fun `로또 번호는 1 ~ 45 사이여야 한다`(rawNumbers: String) { + val numbers = + rawNumbers.split(",") + .map { it.toInt() } + shouldThrow { + LottoNumbers(numbers) + }.apply { + message shouldBe "로또 번호는 ${LottoNumbers.LOTTO_MIN_NUMBER} ~ ${LottoNumbers.LOTTO_MAX_NUMBER} 사이여야 합니다." + } + } + + @ParameterizedTest + @ValueSource( + strings = [ + "1,2,1,4,5,6", + "1,2,3,3,3,3", + "1,1,1,1,1,1", + "1,2,3,4,5,5", + ], + ) + fun `로또 번호는 중복될 수 없다`(rawNumbers: String) { + val numbers = + rawNumbers.split(",") + .map { it.toInt() } + shouldThrow { + LottoNumbers(numbers) + }.apply { + message shouldBe "로또 번호는 중복될 수 없습니다." + } + } +} diff --git a/src/test/kotlin/lotto/model/process/LottoMachineProcessTest.kt b/src/test/kotlin/lotto/model/process/LottoMachineProcessTest.kt new file mode 100644 index 0000000000..afe0ae4fb8 --- /dev/null +++ b/src/test/kotlin/lotto/model/process/LottoMachineProcessTest.kt @@ -0,0 +1,29 @@ +package lotto.model.process + +import io.kotest.matchers.shouldBe +import lotto.model.price.Lotto2024Price +import lotto.model.rank.LottoWinningRank +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class LottoMachineProcessTest { + @ParameterizedTest + @ValueSource( + strings = [ + "1000,14000", + "2000,14000", + "3000,14000", + ], + ) + fun `구입금액, 구매가능 개수 테스트`(rawInput: String) { + val (price, purchaseAmount) = + rawInput.split(",") + .map { it.toInt() } + + val lottoPrice = Lotto2024Price(price) + val lottoRank = LottoWinningRank() + val process = LottoMachineProcess(lottoPrice, lottoRank) + val count = process.calculateLottoCount(purchaseAmount) + count shouldBe purchaseAmount.div(price) + } +}