diff --git a/README.md b/README.md index b168a180..5ba2ce4d 100644 --- a/README.md +++ b/README.md @@ -1 +1,268 @@ -# javascript-planetlotto-precourse + \ No newline at end of file diff --git a/src/App.js b/src/App.js index 091aa0a5..13626625 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,10 @@ +import LottoController from "./controller/LottoController.js"; + class App { - async run() {} + async run() { + const controller = new LottoController; + await controller.run(); + } } export default App; diff --git a/src/constants/ErrorMesssage.js b/src/constants/ErrorMesssage.js new file mode 100644 index 00000000..7f449922 --- /dev/null +++ b/src/constants/ErrorMesssage.js @@ -0,0 +1,17 @@ +const ErrorMessage = { + PURCHASE_AMOUNT_EMPTY: '로또 구입 금액을 입력해 주세요.', + PURCHASE_AMOUNT_NOT_NUMBER: '로또의 구입금액은 500원 단위 숫자여야 합니다.', + PURCHASE_AMOUNT_NOT_DIVISIBLE: '로또의 구입금액은 500원 단위여야 합니다.', + PURCHASE_AMOUNT_NOT_POSITIVE: "로또의 구입금액은 500원 단위 양수여야 합니다.", + + WINNING_NUMBERS_EMPTY: '로또 당첨번호를 입력해 주세요.', + WINNING_NUMBERS_INVALID_COUNT: '로또 번호는 5개여야 합니다.', + WINNING_NUMBERS_INVALID_RANGE: '로또 번호는 1부터 30 사이의 숫자여야 합니다.', + WINNING_NUMBERS_DUPLICATE: '로또 당첨 번호에 중복된 숫자가 있습니다.', + + BONUS_NUMBER_EMPTY: '보너스 번호를 입력해 주세요.', + BONUS_NUMBER_INVALID_RANGE: '보너스 번호는 1부터 30 사이의 정수여야 합니다.', + BONUS_NUMBER_DUPLICATE: '보너스 번호는 당첨 번호와 중복될 수 없습니다.', +}; + +export default ErrorMessage; diff --git a/src/constants/LottoConstants.js b/src/constants/LottoConstants.js new file mode 100644 index 00000000..03f389ff --- /dev/null +++ b/src/constants/LottoConstants.js @@ -0,0 +1,9 @@ +class LottoConstants { + static LOTTO_PRICE = 500; + static LOTTO_NUMBER_COUNT = 5; + static MIN_NUMBER = 1; + static MAX_NUMBER = 30; +} + +export default LottoConstants; + diff --git a/src/constants/PrizeConstants.js b/src/constants/PrizeConstants.js new file mode 100644 index 00000000..a0445b3f --- /dev/null +++ b/src/constants/PrizeConstants.js @@ -0,0 +1,36 @@ +class PrizeConstants { + static RANK = { + FIRST: 1, + SECOND: 2, + THIRD: 3, + FOURTH: 4, + FIFTH: 5, + NONE: 0, + }; + + static MATCH_COUNT = { + FIRST: 5, + SECOND: 4, + THIRD: 4, + FOURTH: 3, + FIFTH: 2, + }; + + static PRIZE_AMOUNTS = { + 1: 100000000, + 2: 10000000, + 3: 1500000, + 4: 500000, + 5: 5000, + }; + + static PROFIT_RATE_MULTIPLIER = 100; + static PROFIT_RATE_DECIMAL_PLACES = 1; +} + +export default PrizeConstants; + + + + + diff --git a/src/constants/PromptMessage.js b/src/constants/PromptMessage.js new file mode 100644 index 00000000..29079581 --- /dev/null +++ b/src/constants/PromptMessage.js @@ -0,0 +1,5 @@ +export const PromptMessages = { + PURCHASE_AMOUNT: "구입금액을 입력해 주세요.\n", + WINNING_NUMBERS: "\n당첨 번호를 입력해 주세요.\n", + BONUS_NUMBER: "\n보너스 번호를 입력해 주세요.\n" +}; diff --git a/src/controller/LottoController.js b/src/controller/LottoController.js new file mode 100644 index 00000000..7ebf3668 --- /dev/null +++ b/src/controller/LottoController.js @@ -0,0 +1,98 @@ +import view from "../view/view.js"; +import InputValidator from "../utils/InputValidator.js"; +import PrizeCalculator from "../utils/PrizeCalculator.js"; +import LottoConstants from "../constants/LottoConstants.js"; +import LottoGenerator from "../utils/LottoGenerator.js"; +import PrizeConstants from "../constants/PrizeConstants.js"; + +const { InputView, OutputView } = view; + +class LottoController { + async run() { + const lottos = await this.purchaseLottos(); + this.printLottos(lottos); + + const winningNumbers = await this.getWinningNumbers(); + const bonusNumber = await this.getBonusNumber(winningNumbers); + + this.calculateResults(lottos, winningNumbers, bonusNumber); + } + + printLottos(lottos) { + const lottoNumbers = lottos.map(lotto => lotto.getNumbers()); + OutputView.printPurchasedLottos(lottoNumbers); + } + + async purchaseLottos() { + while (true) { + try { + const amount = await InputView.askAmount(); + InputValidator.validatePurchaseAmount(amount); + + const lottoCount = this.calculateLottoCount(amount); + return LottoGenerator.generateLottos(lottoCount); + } catch (error) { + OutputView.printErrorMessage(error.message); + } + } + } + + calculateLottoCount(purchaseAmount) { + return purchaseAmount / LottoConstants.LOTTO_PRICE; + } + + async getWinningNumbers() { + while (true) { + try { + const numbers = await InputView.askWinningLotto(); + // InputValidator의 개별 검증 메서드 사용 + InputValidator.validateNumberCount(numbers); + InputValidator.validateNumberRange(numbers); + InputValidator.validateUniqueNumbers(numbers); + return numbers; + } catch (error) { + OutputView.printErrorMessage(error.message); + } + } + } + + async getBonusNumber(winningNumbers) { + while (true) { + try { + const bonusNumber = await InputView.askBonusNumber(); + InputValidator.validateBonusRange(bonusNumber); + InputValidator.validateBonusDuplicate(bonusNumber, winningNumbers); + return bonusNumber; + } catch (error) { + OutputView.printErrorMessage(error.message); + } + } + } + + calculateResults(lottos, winningNumbers, bonusNumber) { + const statistics = PrizeCalculator.calculateStatistics( + lottos, + winningNumbers, + bonusNumber + ); + this.printResults(statistics, lottos.length); + } + + printResults(statistics, lottoCount) { + // Map 형식으로 변환 (0~5 등급) + const countsByRank = new Map(); + const totalWinning = (statistics[1] || 0) + (statistics[2] || 0) + (statistics[3] || 0) + (statistics[4] || 0) + (statistics[5] || 0); + const noMatchCount = lottoCount - totalWinning; + + countsByRank.set(0, noMatchCount); + countsByRank.set(1, statistics[1] || 0); + countsByRank.set(2, statistics[2] || 0); + countsByRank.set(3, statistics[3] || 0); + countsByRank.set(4, statistics[4] || 0); + countsByRank.set(5, statistics[5] || 0); + + OutputView.printResult(countsByRank); + } +} + +export default LottoController; \ No newline at end of file diff --git a/src/model/Lotto.js b/src/model/Lotto.js new file mode 100644 index 00000000..87bfbf87 --- /dev/null +++ b/src/model/Lotto.js @@ -0,0 +1,57 @@ +import LottoConstants from "../constants/LottoConstants.js"; +import ErrorMessage from "../constants/ErrorMesssage.js"; + +class Lotto { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = [...numbers].sort((a, b) => a - b); + } + + #validate(numbers) { + this.#validateCount(numbers); + this.#validateDuplicate(numbers); + this.#validateRange(numbers); + } + + #validateCount(numbers) { + if (numbers.length !== LottoConstants.LOTTO_NUMBER_COUNT) { + throw new Error(ErrorMessage.WINNING_NUMBERS_INVALID_COUNT); + } + } + + #validateDuplicate(numbers) { + const uniqueNumbers = new Set(numbers); + if (uniqueNumbers.size !== numbers.length) { + throw new Error(ErrorMessage.WINNING_NUMBERS_DUPLICATE); + } + } + + #validateRange(numbers) { + const outOfRange = numbers.some( + (number) => + number < LottoConstants.MIN_NUMBER || + number > LottoConstants.MAX_NUMBER + ); + if (outOfRange) { + throw new Error(ErrorMessage.WINNING_NUMBERS_INVALID_RANGE); + } + } + + getNumbers() { + return [...this.#numbers]; + } + + countMatchingNumbers(winningNumbers) { + return this.#numbers.filter((number) => + winningNumbers.includes(number) + ).length; + } + + hasBonusNumber(bonusNumber) { + return this.#numbers.includes(bonusNumber); + } +} + +export default Lotto; \ No newline at end of file diff --git a/src/utils/InputValidator.js b/src/utils/InputValidator.js new file mode 100644 index 00000000..e57675ee --- /dev/null +++ b/src/utils/InputValidator.js @@ -0,0 +1,142 @@ +import LottoConstants from "../constants/LottoConstants.js"; +import ErrorMessage from "../constants/ErrorMesssage.js"; + +class InputValidator { + + static validatePurchaseAmount(amount) { + this.validateAmountType(amount); + this.validateAmountDivisible(amount); + this.validateAmountPositive(amount); + } + + static validateAmountType(amount) { + const trimmedAmount = String(amount).trim(); + if (trimmedAmount === "") { + throw new Error(ErrorMessage.PURCHASE_AMOUNT_EMPTY); + } + + const amountNumber = Number(trimmedAmount); + if (isNaN(amountNumber)) { + throw new Error(ErrorMessage.PURCHASE_AMOUNT_NOT_NUMBER); + } + + if (!Number.isInteger(amountNumber)) { + throw new Error(ErrorMessage.PURCHASE_AMOUNT_NOT_NUMBER); + } + } + + static validateAmountDivisible(amount) { + const amountNumber = Number(amount); + if (amountNumber % LottoConstants.LOTTO_PRICE !== 0) { + throw new Error(ErrorMessage.PURCHASE_AMOUNT_NOT_DIVISIBLE); + } + } + + static validateAmountPositive(amount) { + const amountNumber = Number(amount); + if (amountNumber <= 0) { + throw new Error(ErrorMessage.PURCHASE_AMOUNT_NOT_POSITIVE); + } + } + + static validateWinningNumbers(input) { + const numbers = this.parseWinningNumbers(input); + this.validateNumberCount(numbers); + this.validateNumberRange(numbers); + this.validateUniqueNumbers(numbers); + return numbers; + } + + static parseWinningNumbers(input) { + if (!input || input.trim() === "") { + throw new Error(ErrorMessage.WINNING_NUMBERS_EMPTY); + } + + const numbers = input.split(",").map((number) => { + const trimmed = number.trim(); + if (trimmed === "") { + throw new Error(ErrorMessage.WINNING_NUMBERS_INVALID_COUNT); + } + + const parsed = Number(trimmed); + if (isNaN(parsed)) { + throw new Error(ErrorMessage.WINNING_NUMBERS_INVALID_RANGE); + } + + if (!Number.isInteger(parsed)) { + throw new Error(ErrorMessage.WINNING_NUMBERS_INVALID_RANGE); + } + + return parsed; + }); + + return numbers; + } + + static validateNumberCount(numbers) { + if (numbers.length !== LottoConstants.LOTTO_NUMBER_COUNT) { + throw new Error(ErrorMessage.WINNING_NUMBERS_INVALID_COUNT); + } + } + + static validateNumberRange(numbers) { + const outOfRange = numbers.some( + (number) => + number < LottoConstants.MIN_NUMBER || + number > LottoConstants.MAX_NUMBER + ); + if (outOfRange) { + throw new Error(ErrorMessage.WINNING_NUMBERS_INVALID_RANGE); + } + } + + static validateUniqueNumbers(numbers) { + const uniqueNumbers = new Set(numbers); + if (uniqueNumbers.size !== numbers.length) { + throw new Error(ErrorMessage.WINNING_NUMBERS_DUPLICATE); + } + } + + static validateBonusNumber(input, winningNumbers) { + const bonusNumber = this.parseBonusNumber(input); + this.validateBonusRange(bonusNumber); + this.validateBonusDuplicate(bonusNumber, winningNumbers); + return bonusNumber; + } + + static parseBonusNumber(input) { + const trimmedInput = input.trim(); + + if (trimmedInput === "") { + throw new Error(ErrorMessage.BONUS_NUMBER_EMPTY); + } + + const bonusNumber = Number(trimmedInput); + if (isNaN(bonusNumber)) { + throw new Error(ErrorMessage.BONUS_NUMBER_INVALID_RANGE); + } + + if (!Number.isInteger(bonusNumber)) { + throw new Error(ErrorMessage.BONUS_NUMBER_INVALID_RANGE); + } + + return bonusNumber; + } + + static validateBonusRange(bonusNumber) { + if ( + bonusNumber < LottoConstants.MIN_NUMBER || + bonusNumber > LottoConstants.MAX_NUMBER + ) { + throw new Error(ErrorMessage.BONUS_NUMBER_INVALID_RANGE); + } + } + + static validateBonusDuplicate(bonusNumber, winningNumbers) { + if (winningNumbers.includes(bonusNumber)) { + throw new Error(ErrorMessage.BONUS_NUMBER_DUPLICATE); + } + } +} + +export default InputValidator; \ No newline at end of file diff --git a/src/utils/LottoGenerator.js b/src/utils/LottoGenerator.js new file mode 100644 index 00000000..a46b57c9 --- /dev/null +++ b/src/utils/LottoGenerator.js @@ -0,0 +1,24 @@ +import { MissionUtils } from "@woowacourse/mission-utils"; +import Lotto from "../model/Lotto.js"; +import LottoConstants from "../constants/LottoConstants.js"; + +class LottoGenerator { + static generateLottoNumbers() { + const numbers = MissionUtils.Random.pickUniqueNumbersInRange( + LottoConstants.MIN_NUMBER, + LottoConstants.MAX_NUMBER, + LottoConstants.LOTTO_NUMBER_COUNT + ); + return new Lotto(numbers); + } + + static generateLottos(count) { + const lottos = []; + for (let i = 0; i < count; i++) { + lottos.push(this.generateLottoNumbers()); + } + return lottos; + } +} + +export default LottoGenerator; \ No newline at end of file diff --git a/src/utils/PrizeCalculator.js b/src/utils/PrizeCalculator.js new file mode 100644 index 00000000..3c27f744 --- /dev/null +++ b/src/utils/PrizeCalculator.js @@ -0,0 +1,77 @@ +import PrizeConstants from "../constants/PrizeConstants.js"; + +class PrizeCalculator { + static determineRank(matchCount, hasBonus) { + if (matchCount === PrizeConstants.MATCH_COUNT.FIRST) { + return PrizeConstants.RANK.FIRST; + } + if ( + matchCount === PrizeConstants.MATCH_COUNT.SECOND && + hasBonus + ) { + return PrizeConstants.RANK.SECOND; + } + if (matchCount === PrizeConstants.MATCH_COUNT.THIRD) { + return PrizeConstants.RANK.THIRD; + } + if (matchCount === PrizeConstants.MATCH_COUNT.FOURTH && + hasBonus + ) { + return PrizeConstants.RANK.FOURTH; + } + if (matchCount === PrizeConstants.MATCH_COUNT.FIFTH && + hasBonus + ) { + return PrizeConstants.RANK.FIFTH; + } + return PrizeConstants.RANK.NONE; + } + + static calculatePrizeAmount(rank) { + if (rank === PrizeConstants.RANK.NONE) { + return 0; + } + return PrizeConstants.PRIZE_AMOUNTS[rank]; + } + + static calculateStatistics(lottos, winningNumbers, bonusNumber) { + const statistics = { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }; + + lottos.forEach((lotto) => { + const matchCount = lotto.countMatchingNumbers(winningNumbers); + const hasBonus = lotto.hasBonusNumber(bonusNumber); + const rank = PrizeCalculator.determineRank(matchCount, hasBonus); + + if (rank !== PrizeConstants.RANK.NONE) { + statistics[rank]++; + } + }); + + return statistics; + } + + static calculateTotalPrizeAmount(statistics) { + let totalAmount = 0; + Object.keys(statistics).forEach((rank) => { + const count = statistics[rank]; + const prizeAmount = PrizeCalculator.calculatePrizeAmount(Number(rank)); + totalAmount += count * prizeAmount; + }); + return totalAmount; + } + + static calculateProfitRate(totalPrize, purchaseAmount) { + const profitRate = + (totalPrize / purchaseAmount) * + PrizeConstants.PROFIT_RATE_MULTIPLIER; + return Number(profitRate.toFixed(PrizeConstants.PROFIT_RATE_DECIMAL_PLACES)); + } +} + +export default PrizeCalculator; \ No newline at end of file diff --git a/src/view.js b/src/view/view.js similarity index 98% rename from src/view.js rename to src/view/view.js index ae6afd9c..78398b17 100644 --- a/src/view.js +++ b/src/view/view.js @@ -67,7 +67,6 @@ const OutputView = { const output = [ '당첨 통계', - '---', `5개 일치 (100,000,000원) - ${getCount(1)}개`, `4개 일치, 보너스 번호 일치 (10,000,000원) - ${getCount(2)}개`, `4개 일치 (1,500,000원) - ${getCount(3)}개`, @@ -87,4 +86,4 @@ const OutputView = { }, }; -export { InputView, OutputView }; +export default { InputView, OutputView };