diff --git a/README.md b/README.md index 15bb106b5..20d00f2b6 100644 --- a/README.md +++ b/README.md @@ -1 +1,33 @@ # javascript-lotto-precourse + +- 간단한 로또 발매기를 구현한 프로젝트이다. + +## 주요 기능 + +### 1. 사용자 입력 처리 + +- [x] 구입 금액 입력 받기 +- [x] 당첨 번호 입력 받기 +- [x] 보너스 번호 입력 받기 + +### 2. 발행된 로또 출력 기능 + +- [x] 입력 받은 금액 / 1000을 통해 출력할 로또의 개수 출력 +- [x] 로또 당 중복 없는 6개의 번호 랜덤 출력 + +### 3. 로또 당첨 결과 출력 기능 + +- [x] 발행된 로또와 입력 받은 번호들의 중복 확인 +- [x] 일치하는 개수에 따라 당첨 통계 출력 +- [x] 당첨금 / 투자금 \* 100의 식으로 수익률 출력 + - [x] 소수점 둘째 자리에서 반올림해야 한다 + +### 4. 예외처리 + +- 구입 금액 예외 + - [x] 구입 금액은 1000으로 나누어 떨어져야 한다 +- 당첨 번호 예외 + - [x] 각 번호가 1~45 사이의 중복되지 않은 6개의 숫자여야 한다 + - [x] ,(쉼표)를 기준으로 입력되어야 한다 +- 보너스 번호 예외 + - [x] 당첨 번호와 다른 1~45 사이의 숫자여야 한다 diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js index 409aaf69b..5a286da70 100644 --- a/__tests__/LottoTest.js +++ b/__tests__/LottoTest.js @@ -1,4 +1,4 @@ -import Lotto from "../src/Lotto"; +import Lotto from "../src/Lotto.js"; describe("로또 클래스 테스트", () => { test("로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.", () => { @@ -7,12 +7,29 @@ describe("로또 클래스 테스트", () => { }).toThrow("[ERROR]"); }); - // TODO: 테스트가 통과하도록 프로덕션 코드 구현 test("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.", () => { expect(() => { new Lotto([1, 2, 3, 4, 5, 5]); }).toThrow("[ERROR]"); }); - // TODO: 추가 기능 구현에 따른 테스트 코드 작성 + test('로또 번호가 1부터 45 사이의 숫자가 아닐 경우 예외가 발생한다.', () => { + expect(() => { + new Lotto([1, 2, 3, 4, 5, 46]); + }).toThrow('[ERROR]'); + }); + + test('당첨 번호와 일치하는 번호 개수를 올바르게 계산한다.', () => { + const lotto = new Lotto([1, 2, 3, 4, 5, 6]); + const winningNumbers = [1, 2, 3, 7, 8, 9]; + const { matchCount } = lotto.match(winningNumbers, 10); + expect(matchCount).toBe(3); + }); + + test('보너스 번호 일치 여부를 올바르게 판단한다.', () => { + const lotto = new Lotto([1, 2, 3, 4, 5, 6]); + const winningNumbers = [1, 2, 3, 4, 5, 7]; + const { hasBonus } = lotto.match(winningNumbers, 6); + expect(hasBonus).toBe(true); + }); }); diff --git a/src/App.js b/src/App.js index 091aa0a5d..0255d2b4d 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,141 @@ +import { LOTTO, PRIZE, MESSAGE, ERROR_MESSAGE } from "./utils/constants.js"; +import { + validatePurchaseAmount, + validateWinningNumbers, + validateBonusNumber, +} from "./utils/validation.js"; +import { + generateLottoNumbers, + readLineAsync, + print, +} from "./utils/missionUtil.js"; +import Lotto from "./Lotto.js"; + class App { - async run() {} + #lottos = []; + #winningNumbers = []; + #bonusNumber = 0; + + async run() { + await this.purchaseLottos(); + await this.inputWinningNumbers(); + await this.inputBonusNumber(); + this.showResult(); + } + + async purchaseLottos() { + const amount = validatePurchaseAmount( + await readLineAsync(MESSAGE.PURCHASE_MESSAGE) + ); + + const count = amount / LOTTO.PRICE; + this.generateLottos(count); + + print(`\n${count}개를 구매했습니다.`); + this.#lottos.forEach((lotto) => { + print(`[${lotto.getNumbers().join(", ")}]`); + }); + print(""); + } + + generateLottos(count) { + this.#lottos = Array.from( + { length: count }, + () => new Lotto(generateLottoNumbers()) + ); + } + + async inputWinningNumbers() { + this.#winningNumbers = validateWinningNumbers( + await readLineAsync(MESSAGE.WINNING_NUMBERS_MESSAGE) + ); + } + + async inputBonusNumber() { + this.#bonusNumber = validateBonusNumber( + await readLineAsync(MESSAGE.BONUS_NUMBER_MESSAGE), + this.#winningNumbers + ); + } + + showResult() { + const results = this.calculateResults(); + const profit = this.calculateProfit(results); + + print("\n당첨 통계\n---"); + this.printPrizeResults(results); + this.printProfitRate(profit); + } + + calculateResults() { + const results = new Map([ + [PRIZE.FIFTH.MATCH, 0], + [PRIZE.FOURTH.MATCH, 0], + [PRIZE.THIRD.MATCH, 0], + [PRIZE.SECOND.MATCH, 0], // 5 matches + bonus + [PRIZE.FIRST.MATCH, 0], + ]); + + this.#lottos.forEach((lotto) => { + const { matchCount, hasBonus } = lotto.match( + this.#winningNumbers, + this.#bonusNumber + ); + + if (matchCount === PRIZE.SECOND.MATCH && hasBonus) { + results.set(PRIZE.SECOND.MATCH, results.get(PRIZE.SECOND.MATCH) + 1); + } else { + results.set(matchCount, results.get(matchCount) || 0 + 1); + } + }); + + return results; + } + + calculateProfit(results) { + const investment = this.#lottos.length * LOTTO.PRICE; + let prize = 0; + + prize += results.get(PRIZE.FIFTH.MATCH) * PRIZE.FIFTH.AMOUNT; + prize += results.get(PRIZE.FOURTH.MATCH) * PRIZE.FOURTH.AMOUNT; + prize += results.get(PRIZE.THIRD.MATCH) * PRIZE.THIRD.AMOUNT; + prize += results.get(PRIZE.SECOND.MATCH) * PRIZE.SECOND.AMOUNT; + prize += results.get(PRIZE.FIRST.MATCH) * PRIZE.FIRST.AMOUNT; + + return (prize / investment) * 100; + } + + printPrizeResults(results) { + print( + `3개 일치 (${PRIZE.FIFTH.AMOUNT.toLocaleString()}원) - ${results.get( + PRIZE.FIFTH.MATCH + )}개` + ); + print( + `4개 일치 (${PRIZE.FOURTH.AMOUNT.toLocaleString()}원) - ${results.get( + PRIZE.FOURTH.MATCH + )}개` + ); + print( + `5개 일치 (${PRIZE.THIRD.AMOUNT.toLocaleString()}원) - ${results.get( + PRIZE.THIRD.MATCH + )}개` + ); + print( + `5개 일치, 보너스 볼 일치 (${PRIZE.SECOND.AMOUNT.toLocaleString()}원) - ${results.get( + PRIZE.SECOND.MATCH + )}개` + ); + print( + `6개 일치 (${PRIZE.FIRST.AMOUNT.toLocaleString()}원) - ${results.get( + PRIZE.FIRST.MATCH + )}개` + ); + } + + printProfitRate(profit) { + print(`총 수익률은 ${profit.toFixed(1)}%입니다.`); + } } export default App; diff --git a/src/Lotto.js b/src/Lotto.js index cb0b1527e..43e8a4f77 100644 --- a/src/Lotto.js +++ b/src/Lotto.js @@ -1,18 +1,28 @@ +import { validateNumbers } from "./utils/validation.js"; +import { PRIZE } from "./utils/constants.js"; + class Lotto { #numbers; constructor(numbers) { - this.#validate(numbers); - this.#numbers = numbers; + validateNumbers(numbers); + this.#numbers = numbers.sort((a, b) => a - b); } - #validate(numbers) { - if (numbers.length !== 6) { - throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); - } + match(winningNumbers, bonusNumber) { + const matchCount = this.#numbers.filter((number) => + winningNumbers.includes(number) + ).length; + + const hasBonus = + matchCount === PRIZE.SECOND.MATCH && this.#numbers.includes(bonusNumber); + + return { matchCount, hasBonus }; } - // TODO: 추가 기능 구현 + getNumbers() { + return [...this.#numbers]; + } } export default Lotto; diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 000000000..fcbfe3a07 --- /dev/null +++ b/src/utils/constants.js @@ -0,0 +1,31 @@ +export const LOTTO = { + PRICE: 1000, + LENGTH: 6, + MIN_NUMBER: 1, + MAX_NUMBER: 45, +}; + +export const PRIZE = { + FIRST: { MATCH: 6, AMOUNT: 2000000000 }, + SECOND: { MATCH: 5, BONUS: true, AMOUNT: 30000000 }, + THIRD: { MATCH: 5, AMOUNT: 1500000 }, + FOURTH: { MATCH: 4, AMOUNT: 50000 }, + FIFTH: { MATCH: 3, AMOUNT: 5000 }, +}; + +export const MESSAGE = { + PURCHASE_MESSAGE: "구입금액을 입력해 주세요.\n", + WINNING_NUMBERS_MESSAGE: "당첨 번호를 입력해 주세요.\n", + BONUS_NUMBER_MESSAGE: "\n보너스 번호를 입력해 주세요.\n", +}; + +export const ERROR_MESSAGE = { + INVALID_PURCHASE_AMOUNT: "[ERROR] 구입 금액은 1000원 단위여야 합니다.", + INVALID_NUMBERS_LENGTH: "[ERROR] 로또 번호는 6개여야 합니다.", + INVALID_WINNING_NUMBERS: + "[ERROR] 당첨 번호는 쉼표로 구분된 6개의 숫자여야 합니다.", + INVALID_NUMBER_RANGE: "[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.", + INVALID_BONUS_NUMBER: + "[ERROR] 보너스 번호는 당첨 번호와 중복되지 않는 1부터 45 사이의 숫자여야 합니다.", + DUPLICATE_NUMBERS: "[ERROR] 중복된 숫자는 사용할 수 없습니다.", +}; diff --git a/src/utils/missionUtil.js b/src/utils/missionUtil.js new file mode 100644 index 000000000..28f943447 --- /dev/null +++ b/src/utils/missionUtil.js @@ -0,0 +1,18 @@ +import { Console, Random } from "@woowacourse/mission-utils"; +import { LOTTO } from "./constants.js"; + +export const readLineAsync = async (message) => { + return Console.readLineAsync(message); +}; + +export const print = (message) => { + return Console.print(message); +}; + +export const generateLottoNumbers = () => { + return Random.pickUniqueNumbersInRange( + LOTTO.MIN_NUMBER, + LOTTO.MAX_NUMBER, + LOTTO.LENGTH + ).sort((a, b) => a - b); +}; diff --git a/src/utils/validation.js b/src/utils/validation.js new file mode 100644 index 000000000..e5a717806 --- /dev/null +++ b/src/utils/validation.js @@ -0,0 +1,39 @@ +import { LOTTO, ERROR_MESSAGE } from "./constants.js"; + +export const validatePurchaseAmount = (amount) => { + const number = Number(amount); + if (isNaN(number) || number % LOTTO.PRICE !== 0 || number < LOTTO.PRICE) { + throw new Error(ERROR_MESSAGE.INVALID_PURCHASE_AMOUNT); + } + return number; +}; + +export const validateWinningNumbers = (input) => { + const numbers = input.split(",").map((num) => Number(num.trim())); + validateNumbers(numbers); + return numbers; +}; + +export const validateBonusNumber = (number, winningNumbers) => { + const bonusNumber = Number(number); + if ( + isNaN(bonusNumber) || + bonusNumber < LOTTO.MIN_NUMBER || + bonusNumber > LOTTO.MAX_NUMBER || + winningNumbers.includes(bonusNumber) + ) { + throw new Error(ERROR_MESSAGE.INVALID_BONUS_NUMBER); + } + return bonusNumber; +}; + +export const validateNumbers = (numbers) => { + const uniqueNumbers = new Set(numbers); + if ( + numbers.length !== LOTTO.LENGTH || + uniqueNumbers.size !== LOTTO.LENGTH || + !numbers.every((num) => num >= LOTTO.MIN_NUMBER && num <= LOTTO.MAX_NUMBER) + ) { + throw new Error(ERROR_MESSAGE.INVALID_NUMBER_RANGE); + } +};