diff --git a/README.md b/README.md index b168a180..da3e0e55 100644 --- a/README.md +++ b/README.md @@ -1 +1,61 @@ # javascript-planetlotto-precourse + +## 프로젝트 구현 기능 목록 + +### 1. 상수 정의 + +- 로또 기본 상수 정의 (번호 범위, 개수, 가격) +- 당첨 등급별 상금 상수 정의 + +### 2. Lotto 클래스 (로또 번호 검증 및 관리) + +- 로또 번호는 5개여야 한다. +- 로또 번호는 1~30 사이여야 한다. +- 로또 번호는 중복되지 않아야 한다. +- 로또 번호는 정수여야 한다. +- 로또 번호를 오름차순으로 정렬하여 반환한다. + +### 3. Rank 클래스 (당첨 등급 판별) + +- 일치 개수와 보너스 번호 일치 여부로 등급을 판별한다. + - 5개 일치: 1등 (100,000,000원) + - 4개 일치 + 보너스 번호 일치: 2등 (10,000,000원) + - 4개 일치: 3등 (1,500,000원) + - 3개 일치 + 보너스 번호 일치: 4등 (500,000원) + - 2개 일치 + 보너스 번호 일치: 5등 (5,000원) + - 0개 일치: (0원) + +### 4. WinningNumber 클래스 (당첨 번호 관리) + +- 당첨 번호는 로또 번호 검증 규칙을 따른다. +- 보너스 번호는 정수여야 한다. +- 보너스 번호는 1~30 사이여야 한다. +- 보너스 번호는 당첨 번호와 중복되지 않아야 한다. + +### 5. LottoMatcher 클래스 (당첨 확인) + +- 구매한 로또와 당첨 번호를 비교하여 일치 개수를 센다. +- 보너스 번호 일치 여부를 확인한다. +- 일치 결과를 바탕으로 당첨 등급을 반환한다. + +### 6. LottoMachine 클래스 (로또 발행) + +- 구매 금액은 최소 1,000원 이상이어야 한다. +- 구매 금액은 1,000원 단위여야 한다. +- 구매 금액에 맞는 개수만큼 로또를 발행한다. +- 로또 번호는 1~45 사이에서 중복 없이 6개 무작위로 생성한다. + +### 7. LottoStatistics 클래스 (통계) + +- 등급별 당첨 개수를 집계한다. +- 총 당첨 금액을 계산한다. + +### 8. LottoController 클래스 (전체 흐름 통합) + +- 구매 금액 입력 및 검증 (예외 발생 시 재입력) +- 로또 발행 및 출력 +- 당첨 번호 및 보너스 번호 입력 및 검증 (예외 발생 시 재입력) +- 당첨 확인 및 통계 계산 +- 결과 출력 +- 예외 발생 시 "[ERROR]"로 시작하는 메시지 출력 +- 예외 발생 시 해당 지점부터 다시 입력받기 diff --git a/package-lock.json b/package-lock.json index 328e25a1..8b17a6cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2985,6 +2986,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001669", "electron-to-chromium": "^1.5.41", diff --git a/src/App.js b/src/App.js index 091aa0a5..6f40b476 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/lottoConstants.js b/src/constants/lottoConstants.js new file mode 100644 index 00000000..b1f28678 --- /dev/null +++ b/src/constants/lottoConstants.js @@ -0,0 +1,12 @@ +export const LOTTO_CONFIG = { + PRICE: 500, + NUMBER_COUNT: 5, + MIN_NUMBER: 1, + MAX_NUMBER: 30, +}; + +export const STATISTICS_CONFIG = { + RETURN_RATE_MULTIPLIER: 500, + RETURN_RATE_DIVISOR: 10, + DECIMAL_PLACES: 1, +}; diff --git a/src/constants/rankConstants.js b/src/constants/rankConstants.js new file mode 100644 index 00000000..fb1f6661 --- /dev/null +++ b/src/constants/rankConstants.js @@ -0,0 +1,17 @@ +export const RANK = { + FIRST: { name: '1등', match: 5, bonus: false, amount: 100_000_000 }, + SECOND: { name: '2등', match: 4, bonus: true, amount: 10_000_000 }, + THIRD: { name: '3등', match: 4, bonus: false, amount: 1_500_000 }, + FOURTH: { name: '4등', match: 3, bonus: true, amount: 500_000 }, + FIFTH: { name: '5등', match: 2, bonus: true, amount: 5_000 }, + ZERO: { name: '0등', match: 0, bonus: false, amount: 0 }, +}; + +export const LOTTO_RANKS = [ + RANK.FIRST, + RANK.SECOND, + RANK.THIRD, + RANK.FOURTH, + RANK.FIFTH, + RANK.ZERO, +]; diff --git a/src/controller/LottoController.js b/src/controller/LottoController.js new file mode 100644 index 00000000..9042f185 --- /dev/null +++ b/src/controller/LottoController.js @@ -0,0 +1,115 @@ +import { LOTTO_CONFIG } from '../constants/lottoConstants.js'; +import InputView from '../view/InputView.js'; +import OutputView from '../view/OutputView.js'; +import LottoMachine from '../domain/LottoMachine.js'; +import WinningNumber from '../domain/WinningNumber.js'; +import LottoMatcher from '../domain/LottoMatcher.js'; +import LottoStatistics from '../domain/LottoStatistics.js'; +import PurchaseAmount from '../domain/PurchaseAmount.js'; + +class LottoController { + #inputView; + #outputView; + + constructor() { + this.#inputView = new InputView(); + this.#outputView = new OutputView(); + } + + async run() { + const { purchaseAmount, lottos } = await this.#purchaseLottos(); + const winningNumber = await this.#getWinningNumber(); + const results = this.#checkWinning(lottos, winningNumber); + this.#printResults(results, purchaseAmount); + } + + async #purchaseLottos() { + while (true) { + try { + const amount = await this.#inputPurchaseAmount(); + const lottos = this.#issueLottos(amount); + this.#printLottos(lottos); + return { purchaseAmount: amount, lottos }; + } catch (error) { + this.#outputView.printError(error.message); + } + } + } + + async #inputPurchaseAmount() { + const input = await this.#inputView.readPurchaseAmount(); + const purchaseAmount = new PurchaseAmount(input); + return purchaseAmount.getValue(); + } + + #issueLottos(amount) { + const machine = new LottoMachine(); + return machine.createLottos(amount); + } + + #printLottos(lottos) { + this.#outputView.printLottos(lottos); + } + + async #getWinningNumber() { + while (true) { + try { + const numbersInput = await this.#inputView.readWinningNumbers(); + const numbers = this.#parseNumbers(numbersInput); + + const bonusInput = await this.#inputView.readBonusNumber(); + const bonus = this.#parseBonus(bonusInput); + + return new WinningNumber(numbers, bonus); + } catch (error) { + this.#outputView.printError(error.message); + } + } + } + + #parseNumbers(input) { + const numbers = input + .split(',') + .map((num) => + this.#validateNumber( + num.trim(), + '[ERROR] 당첨 번호는 숫자여야 합니다!', + ), + ); + + if (numbers.length !== LOTTO_CONFIG.NUMBER_COUNT) { + throw new Error('[ERROR] 로또 번호는 5개여야 합니다!'); + } + + return numbers; + } + + #parseBonus(input) { + return this.#validateNumber( + input.trim(), + '[ERROR] 보너스 번호는 숫자여야 합니다!', + ); + } + + #checkWinning(lottos, winningNumber) { + return lottos.map((lotto) => { + const matcher = new LottoMatcher(lotto, winningNumber); + return matcher.determineRank(); + }); + } + + #printResults(results, purchaseAmount) { + const statistics = new LottoStatistics(results); + this.#outputView.printStatistics(statistics, purchaseAmount); + } + + #validateNumber(value, errorMessage) { + const parsed = Number(value); + if (Number.isNaN(parsed)) { + throw new Error(errorMessage); + } + return parsed; + } +} + +export default LottoController; diff --git a/src/domain/Lotto.js b/src/domain/Lotto.js new file mode 100644 index 00000000..01e4fc91 --- /dev/null +++ b/src/domain/Lotto.js @@ -0,0 +1,66 @@ +import { LOTTO_CONFIG } from '../constants/lottoConstants.js'; + +class Lotto { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = numbers; + } + + #validate(numbers) { + this.#validateType(numbers); + this.#validateLength(numbers); + this.#validateDuplicate(numbers); + this.#validateRange(numbers); + } + + #validateType(numbers) { + if (numbers.some((num) => !Number.isInteger(num))) { + throw new Error('[ERROR] 로또 번호는 숫자로만 입력 가능합니다!'); + } + } + + #validateLength(numbers) { + if (numbers.length !== LOTTO_CONFIG.NUMBER_COUNT) { + throw new Error('[ERROR] 로또 번호는 5개여야 합니다!'); + } + } + + #validateDuplicate(numbers) { + if (new Set(numbers).size !== numbers.length) { + throw new Error('[ERROR] 중복된 숫자가 있으면 안됩니다!'); + } + } + + #validateRange(numbers) { + if ( + numbers.some( + (num) => num < LOTTO_CONFIG.MIN_NUMBER || num > LOTTO_CONFIG.MAX_NUMBER, + ) + ) { + throw new Error('[ERROR] 로또 번호는 1~45 사이여야 합니다!'); + } + } + + countMatchesWith(otherLotto) { + let count = 0; + for (const number of this.#numbers) { + if (otherLotto.hasNumber(number)) { + count++; + } + } + return count; + } + + hasNumber(number) { + return this.#numbers.includes(number); + } + + formatSorted() { + const sorted = [...this.#numbers].sort((a, b) => a - b); + return `[${sorted.join(', ')}]`; + } +} + +export default Lotto; diff --git a/src/domain/LottoMachine.js b/src/domain/LottoMachine.js new file mode 100644 index 00000000..6ca8cb93 --- /dev/null +++ b/src/domain/LottoMachine.js @@ -0,0 +1,39 @@ +import { Random } from '@woowacourse/mission-utils'; +import Lotto from './Lotto.js'; +import { LOTTO_CONFIG } from '../constants/lottoConstants.js'; + +class LottoMachine { + createLottos(purchaseAmount) { + this.#validatePurchaseAmount(purchaseAmount); + + const count = purchaseAmount / LOTTO_CONFIG.PRICE; + const lottos = []; + + for (let i = 0; i < count; i++) { + lottos.push(this.#createLotto()); + } + + return lottos; + } + + #validatePurchaseAmount(amount) { + if (amount < LOTTO_CONFIG.PRICE) { + throw new Error('[ERROR] 구입 금액은 최소 500원 부터입니다!'); + } + + if (amount % LOTTO_CONFIG.PRICE !== 0) { + throw new Error('[ERROR] 구입 금액이 500원 단위가 아닙니다!'); + } + } + + #createLotto() { + const numbers = Random.pickUniqueNumbersInRange( + LOTTO_CONFIG.MIN_NUMBER, + LOTTO_CONFIG.MAX_NUMBER, + LOTTO_CONFIG.NUMBER_COUNT, + ); + return new Lotto(numbers); + } +} + +export default LottoMachine; diff --git a/src/domain/LottoMatcher.js b/src/domain/LottoMatcher.js new file mode 100644 index 00000000..1cf7ff1f --- /dev/null +++ b/src/domain/LottoMatcher.js @@ -0,0 +1,17 @@ +import Rank from './Rank.js'; + +class LottoMatcher { + #matchCount; + #hasBonus; + + constructor(lotto, winningNumber) { + this.#matchCount = winningNumber.countMatchesWith(lotto); + this.#hasBonus = winningNumber.isBonusMatch(lotto); + } + + determineRank() { + return Rank.from(this.#matchCount, this.#hasBonus); + } +} + +export default LottoMatcher; diff --git a/src/domain/LottoStatistics.js b/src/domain/LottoStatistics.js new file mode 100644 index 00000000..fb2acbcc --- /dev/null +++ b/src/domain/LottoStatistics.js @@ -0,0 +1,83 @@ +import { LOTTO_RANKS } from '../constants/rankConstants.js'; +import { STATISTICS_CONFIG } from '../constants/lottoConstants.js'; + +class LottoStatistics { + #statistics; + #totalPrize; + + constructor(results) { + this.#statistics = this.#buildStatistics(results); + this.#totalPrize = this.#calculateTotalPrize(); + } + + formatStatisticsMessage() { + const messages = []; + LOTTO_RANKS.forEach((rank) => { + const count = this.getRankCount(rank); + let matchInfo = `${rank.match}개 일치`; + + if (rank.bonus) { + matchInfo = `${rank.match}개 일치, 보너스 번호 일치`; + } + + const amount = rank.amount.toLocaleString(); + messages.push(`${matchInfo} (${amount}원) - ${count}개`); + }); + return messages; + } + + calculateReturnRate(purchaseAmount) { + if (purchaseAmount === 0) { + return 0; + } + + const { RETURN_RATE_MULTIPLIER, RETURN_RATE_DIVISOR } = STATISTICS_CONFIG; + return ( + Math.round((this.#totalPrize / purchaseAmount) * RETURN_RATE_MULTIPLIER) / + RETURN_RATE_DIVISOR + ); + } + + getRankCount(rank) { + return this.#statistics.get(rank) || 0; + } + + getTotalPrize() { + return this.#totalPrize; + } + + getStatistics() { + return new Map(this.#statistics); + } + + #buildStatistics(results) { + const statistics = new Map(); + + // 모든 등급을 0으로 초기화 + LOTTO_RANKS.forEach((rank) => { + statistics.set(rank, 0); + }); + + // 결과를 순회하며 카운트를 증가 + results.forEach((rank) => { + if (rank !== null) { + statistics.set(rank, statistics.get(rank) + 1); + } + }); + + return statistics; + } + + // 당첨 금액 총합 + #calculateTotalPrize() { + let total = 0; + + this.#statistics.forEach((count, rank) => { + total += rank.amount * count; + }); + + return total; + } +} + +export default LottoStatistics; diff --git a/src/domain/PurchaseAmount.js b/src/domain/PurchaseAmount.js new file mode 100644 index 00000000..be4661c2 --- /dev/null +++ b/src/domain/PurchaseAmount.js @@ -0,0 +1,39 @@ +import { LOTTO_CONFIG } from '../constants/lottoConstants.js'; + +class PurchaseAmount { + #value; + + constructor(input) { + const amount = this.#parseAmount(input); + this.#validate(amount); + this.#value = amount; + } + + #parseAmount(input) { + const amount = Number(input); + if (Number.isNaN(amount)) { + throw new Error('[ERROR] 구입 금액은 숫자여야 합니다!'); + } + return amount; + } + + #validate(amount) { + if (amount < LOTTO_CONFIG.PRICE) { + throw new Error('[ERROR] 구입 금액은 최소 500원 부터입니다!'); + } + + if (amount % LOTTO_CONFIG.PRICE !== 0) { + throw new Error('[ERROR] 구입 금액이 500원 단위가 아닙니다!'); + } + } + + getValue() { + return this.#value; + } + + getLottoCount() { + return this.#value / LOTTO_CONFIG.PRICE; + } +} + +export default PurchaseAmount; diff --git a/src/domain/Rank.js b/src/domain/Rank.js new file mode 100644 index 00000000..a3c8596c --- /dev/null +++ b/src/domain/Rank.js @@ -0,0 +1,15 @@ +import { RANK } from '../constants/rankConstants.js'; + +class Rank { + static from(matchCount, hasBonus) { + if (matchCount === 5) return RANK.FIRST; + if (matchCount === 4 && hasBonus) return RANK.SECOND; + if (matchCount === 4) return RANK.THIRD; + if (matchCount === 3 && hasBonus) return RANK.FOURTH; + if (matchCount === 2 && hasBonus) return RANK.FIFTH; + if (matchCount === 0) return RANK.ZERO; + return null; + } +} + +export default Rank; diff --git a/src/domain/WinningNumber.js b/src/domain/WinningNumber.js new file mode 100644 index 00000000..9d1511a3 --- /dev/null +++ b/src/domain/WinningNumber.js @@ -0,0 +1,48 @@ +import { LOTTO_CONFIG } from '../constants/lottoConstants.js'; +import Lotto from './Lotto.js'; +import NumberValidator from '../utils/NumberValidator.js'; + +class WinningNumber { + #winningLotto; + #bonusNumber; + + constructor(numbers, bonusNumber) { + this.#winningLotto = new Lotto(numbers); + const parsedBonus = this.#validateBonusType(bonusNumber); + this.#validateBonusRange(parsedBonus); + this.#validateBonusDuplicate(numbers, parsedBonus); + this.#bonusNumber = parsedBonus; + } + + #validateBonusType(bonusNumber) { + return NumberValidator.validateInteger( + bonusNumber, + '[ERROR] 보너스 번호는 숫자여야 합니다!', + ); + } + + #validateBonusRange(bonusNumber) { + NumberValidator.validateRange( + bonusNumber, + LOTTO_CONFIG.MIN_NUMBER, + LOTTO_CONFIG.MAX_NUMBER, + '[ERROR] 보너스 번호는 1 이상 30 이하의 수만 가능합니다!', + ); + } + + #validateBonusDuplicate(numbers, bonusNumber) { + if (numbers.includes(bonusNumber)) { + throw new Error('[ERROR] 당첨 번호와 보너스 번호는 중복되면 안됩니다!'); + } + } + + countMatchesWith(lotto) { + return this.#winningLotto.countMatchesWith(lotto); + } + + isBonusMatch(lotto) { + return lotto.hasNumber(this.#bonusNumber); + } +} + +export default WinningNumber; diff --git a/src/utils/NumberValidator.js b/src/utils/NumberValidator.js new file mode 100644 index 00000000..745eb39a --- /dev/null +++ b/src/utils/NumberValidator.js @@ -0,0 +1,21 @@ +class NumberValidator { + static validateInteger(value, errorMessage) { + if (value === null || value === undefined || value === '') { + throw new Error(errorMessage); + } + + const parsed = Number(value); + if (Number.isNaN(parsed) || !Number.isInteger(parsed)) { + throw new Error(errorMessage); + } + return parsed; + } + + static validateRange(value, min, max, errorMessage) { + if (value < min || value > max) { + throw new Error(errorMessage); + } + } +} + +export default NumberValidator; diff --git a/src/view/InputView.js b/src/view/InputView.js new file mode 100644 index 00000000..093a41d9 --- /dev/null +++ b/src/view/InputView.js @@ -0,0 +1,19 @@ +import { Console } from '@woowacourse/mission-utils'; + +class InputView { + async readPurchaseAmount() { + return await Console.readLineAsync('구입금액을 입력해 주세요.\n'); + } + + async readWinningNumbers() { + return await Console.readLineAsync( + '\n지난 주 당첨 번호를 입력해 주세요.\n', + ); + } + + async readBonusNumber() { + return await Console.readLineAsync('\n보너스 번호를 입력해 주세요.\n'); + } +} + +export default InputView; diff --git a/src/view/OutputView.js b/src/view/OutputView.js new file mode 100644 index 00000000..ad0fc172 --- /dev/null +++ b/src/view/OutputView.js @@ -0,0 +1,26 @@ +import { Console } from '@woowacourse/mission-utils'; + +class OutputView { + printLottos(lottos) { + Console.print(`\n${lottos.length}개를 구매했습니다.`); + + lottos.forEach((lotto) => { + Console.print(lotto.formatSorted()); + }); + } + + printStatistics(statistics) { + Console.print(''); + Console.print('당첨 통계'); + Console.print('---'); + + const messages = statistics.formatStatisticsMessage(); + messages.forEach((message) => Console.print(message)); + } + + printError(message) { + Console.print(message); + } +} + +export default OutputView;