diff --git a/README.md b/README.md index 15bb106b5..1cd763428 100644 --- a/README.md +++ b/README.md @@ -1 +1,47 @@ -# javascript-lotto-precourse +# javascript-racingcar-precourse + +## 기능 요구 사항 + +- 로또 번호의 숫자 범위는 1~45까지이다. +- 1개의 로또를 발행할 때 중복되지 않는 6개의 숫자를 뽑는다. +- 당첨 번호 추첨 시 중복되지 않는 숫자 6개와 보너스 번호 1개를 뽑는다. +- 당첨은 1등부터 5등까지 있다. 당첨 기준과 금액은 아래와 같다. +- 1등: 6개 번호 일치 / 2,000,000,000원 +- 2등: 5개 번호 + 보너스 번호 일치 / 30,000,000원 +- 3등: 5개 번호 일치 / 1,500,000원 +- 4등: 4개 번호 일치 / 50,000원 +- 5등: 3개 번호 일치 / 5,000원 +- 로또 구입 금액을 입력하면 구입 금액에 해당하는 만큼 로또를 발행해야 한다. +- 로또 1장의 가격은 1,000원이다. +- 당첨 번호와 보너스 번호를 입력받는다. +- 사용자가 구매한 로또 번호와 당첨 번호를 비교하여 당첨 내역 및 수익률을 출력하고 로또 게임을 종료한다. +- 사용자가 잘못된 값을 입력할 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시키고 해당 메시지를 출력한 다음 해당 지점부터 다시 입력을 받는다. + +## 기능 목록 단위 + +- [x] 1. 로또 구매 로직 구현 + + - [x] 1-1. 사용자로부터 로또 구입 금액 입력받기 + - [x] 입력된 금액이 1000원 단위로 나누어 떨어지지 않거나, 잘못된 입력 시 다시 입력. + - [x] 1-2. 구입 금액에 따라 구매할 로또 수량을 계산하기 + - [x] 1-3. 구매한 로또 수량 만큼 로또 번호 생성하고 출력하기 + - [x] 로또 번호는 오름차순으로 정렬. + +- [x] 2. 당첨 번호 및 보너스 번호 입력 로직 구현 + + - [x] 2-1. 사용자로부터 당첨 번호 6개와 보너스 번호 1개를 입력받기 + - [x] 입력된 번호가 1부터 45사이의 정수가 아니거나, 중복이 있거나, 쉼표로 구분되지 않을 시 다시 입력. + +- [x] 3. 당첨 내역 비교 및 결과 계산 + + - [x] 3-1. 구매한 로또 번호와 당첨 번호를 비교하여 당첨 등수 계산하기 + - [x] 3-2. 1등부터 5등까지의 당첨 횟수 출력하기 + +- [x] 4. 수익률 계산 및 출력 + + - [x] 4-1. 전체 당첨 금액 계산하기. + - [x] 4-2. 수익률을 계산해 소수점 둘째 자리에서 반올림하여 출력하기. + +- [x] 5. 테스트 코드 작성 + + - [x] Jest 를 이용하여, 위 1, 2, 3, 4 가 제대로 기능하는지, 출력값은 정확한지를 테스트한다. diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js index 872380c9c..aaa87b4df 100644 --- a/__tests__/ApplicationTest.js +++ b/__tests__/ApplicationTest.js @@ -3,10 +3,8 @@ import { MissionUtils } from "@woowacourse/mission-utils"; const mockQuestions = (inputs) => { MissionUtils.Console.readLineAsync = jest.fn(); - MissionUtils.Console.readLineAsync.mockImplementation(() => { const input = inputs.shift(); - return Promise.resolve(input); }); }; @@ -25,30 +23,25 @@ const getLogSpy = () => { }; const runException = async (input) => { - // given const logSpy = getLogSpy(); - const RANDOM_NUMBERS_TO_END = [1, 2, 3, 4, 5, 6]; const INPUT_NUMBERS_TO_END = ["1000", "1,2,3,4,5,6", "7"]; mockRandoms([RANDOM_NUMBERS_TO_END]); mockQuestions([input, ...INPUT_NUMBERS_TO_END]); - // when const app = new App(); await app.run(); - // then expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[ERROR]")); }; -describe("로또 테스트", () => { +describe("로또 애플리케이션 테스트", () => { beforeEach(() => { jest.restoreAllMocks(); }); test("기능 테스트", async () => { - // given const logSpy = getLogSpy(); mockRandoms([ @@ -63,11 +56,9 @@ describe("로또 테스트", () => { ]); mockQuestions(["8000", "1,2,3,4,5,6", "7"]); - // when const app = new App(); await app.run(); - // then const logs = [ "8개를 구매했습니다.", "[8, 21, 23, 41, 42, 43]", @@ -91,7 +82,11 @@ describe("로또 테스트", () => { }); }); - test("예외 테스트", async () => { + test("예외 테스트 - 유효하지 않은 금액 입력", async () => { await runException("1000j"); }); + + test("예외 테스트 - 유효하지 않은 당첨 번호 입력", async () => { + await runException("1,2,3,4,5,abc"); + }); }); diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js index 409aaf69b..bcdb1ab05 100644 --- a/__tests__/LottoTest.js +++ b/__tests__/LottoTest.js @@ -1,4 +1,4 @@ -import Lotto from "../src/Lotto"; +import Lotto from "../models/lotto.js"; describe("로또 클래스 테스트", () => { test("로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.", () => { diff --git a/constants/lottoConstants.js b/constants/lottoConstants.js new file mode 100644 index 000000000..354d6ae7f --- /dev/null +++ b/constants/lottoConstants.js @@ -0,0 +1,14 @@ +export const lotto = { + PRICE: 1000, + NUMBER_COUNT: 6, + NUMBER_MIN: 1, + NUMBER_MAX: 45, +}; + +export const prizeMoney = { + FIRST: 2000000000, + SECOND: 30000000, + THIRD: 1500000, + FOURTH: 50000, + FIFTH: 5000, +}; diff --git a/models/Lotto.js b/models/Lotto.js new file mode 100644 index 000000000..cd8951bd7 --- /dev/null +++ b/models/Lotto.js @@ -0,0 +1,18 @@ +import { winningNumbersValidate } from "../modules/inputValidator.js"; + +export default class Lotto { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = numbers; + } + + #validate(numbers) { + winningNumbersValidate(numbers.join(",")); + } + + getNumbers() { + return this.#numbers; + } +} diff --git a/modules/inputValidator.js b/modules/inputValidator.js new file mode 100644 index 000000000..ed6f246d5 --- /dev/null +++ b/modules/inputValidator.js @@ -0,0 +1,74 @@ +import throwError from "../utils/throwError.js"; + +export function purchaseAmountValidate(amount) { + if (!amount.trim()) { + throwError("구입 금액을 입력해 주세요."); + } + + if (isNaN(amount)) { + throwError("구입 금액은 숫자로만 입력해 주세요."); + } + + const amountNumber = Number(amount); + + if (amountNumber <= 0 || amountNumber % 1000 !== 0) { + throwError("구입 금액은 1,000원 단위의 양의 정수로 입력해 주세요."); + } +} + +export function winningNumbersValidate(numbers) { + const numbersArray = numbers.split(","); + const numbersSet = new Set(numbersArray); + + if (!numbers.trim()) { + throwError("번호를 입력해 주세요."); + } + + if (numbersArray.length !== 6) { + throwError("로또 번호는 6개여야 합니다."); + } + + if (numbersSet.size !== numbersArray.length) { + throwError("중복된 번호가 있습니다."); + } + + numbersArray.forEach((number) => { + const numericValue = Number(number); + + if (isNaN(number)) { + throwError("잘못된 입력이거나, 쉼표로 구분된 숫자열이 아닙니다."); + } + + if (!Number.isInteger(numericValue)) { + throwError("번호는 정수로 입력해주세요."); + } + + if (numericValue < 1 || numericValue > 45) { + throwError("숫자는 1 과 45 사이로 입력해주세요."); + } + }); +} + +export function bonusNumberValidate(number, pickedNumbers) { + const numericValue = Number(number); + + if (!number.trim()) { + throwError("번호를 입력해 주세요."); + } + + if (isNaN(number)) { + throwError("번호는 숫자로 입력해 주세요."); + } + + if (!Number.isInteger(numericValue)) { + throwError("번호는 정수로 입력해주세요."); + } + + if (numericValue < 1 || numericValue > 45) { + throwError("숫자는 1 과 45 사이로 입력해주세요."); + } + + if (pickedNumbers.some((number) => number === numericValue)) { + throwError("이전에 선택한 숫자와 중복된 숫자입니다."); + } +} diff --git a/modules/lottoService.js b/modules/lottoService.js new file mode 100644 index 000000000..1dcb152b8 --- /dev/null +++ b/modules/lottoService.js @@ -0,0 +1,115 @@ +import { Console, Random } from "@woowacourse/mission-utils"; +import { lotto, prizeMoney } from "../constants/lottoConstants.js"; +import { calculateYield, getMatchedCount } from "../utils/lottoUtils.js"; +import Lotto from "../models/lotto.js"; + +export function generateLottos(count) { + const lottos = []; + + for (let lottoCount = 0; lottoCount < count; lottoCount++) { + const numbers = Random.pickUniqueNumbersInRange( + lotto.NUMBER_MIN, + lotto.NUMBER_MAX, + lotto.NUMBER_COUNT + ); + + numbers.sort((a, b) => a - b); + + lottos.push(new Lotto(numbers)); + } + + return lottos; +} + +export function printLottos(lottos) { + Console.print(`\n${lottos.length}개를 구매했습니다.`); + + lottos.forEach((lotto) => { + Console.print(`[${lotto.getNumbers().join(", ")}]`); + }); +} + +function calculateWinsCount(lottos, winningNumbers, bonusNumber) { + const resultCount = { + FIRST: 0, + SECOND: 0, + THIRD: 0, + FOURTH: 0, + FIFTH: 0, + }; + + lottos.forEach((lotto) => { + const matchedCount = getMatchedCount(lotto.getNumbers(), winningNumbers); + const hasBonus = lotto.getNumbers().includes(bonusNumber); + + if (matchedCount === 6) { + resultCount.FIRST += 1; + return; + } + + if (matchedCount === 5 && hasBonus) { + resultCount.SECOND += 1; + return; + } + + if (matchedCount === 5) { + resultCount.THIRD += 1; + return; + } + + if (matchedCount === 4) { + resultCount.FOURTH += 1; + return; + } + + if (matchedCount === 3) { + resultCount.FIFTH += 1; + return; + } + }); + + return resultCount; +} + +function calculateLottoYield(resultCount, purchaseAmount) { + const totalPrize = + resultCount.FIRST * prizeMoney.FIRST + + resultCount.SECOND * prizeMoney.SECOND + + resultCount.THIRD * prizeMoney.THIRD + + resultCount.FOURTH * prizeMoney.FOURTH + + resultCount.FIFTH * prizeMoney.FIFTH; + + return calculateYield(totalPrize, purchaseAmount); +} + +export function printLottoResult( + lottos, + winningNumbers, + bonusNumber, + purchaseAmount +) { + const resultCount = calculateWinsCount(lottos, winningNumbers, bonusNumber); + const lottoYield = calculateLottoYield(resultCount, purchaseAmount); + + Console.print("\n당첨 통계\n---"); + Console.print( + `3개 일치 (${prizeMoney.FIFTH.toLocaleString()}원) - ${resultCount.FIFTH}개` + ); + Console.print( + `4개 일치 (${prizeMoney.FOURTH.toLocaleString()}원) - ${ + resultCount.FOURTH + }개` + ); + Console.print( + `5개 일치 (${prizeMoney.THIRD.toLocaleString()}원) - ${resultCount.THIRD}개` + ); + Console.print( + `5개 일치, 보너스 볼 일치 (${prizeMoney.SECOND.toLocaleString()}원) - ${ + resultCount.SECOND + }개` + ); + Console.print( + `6개 일치 (${prizeMoney.FIRST.toLocaleString()}원) - ${resultCount.FIRST}개` + ); + Console.print(`총 수익률은 ${lottoYield}%입니다.`); +} diff --git a/modules/userInput.js b/modules/userInput.js new file mode 100644 index 000000000..a3ecfcc51 --- /dev/null +++ b/modules/userInput.js @@ -0,0 +1,51 @@ +import { Console } from "@woowacourse/mission-utils"; +import { + bonusNumberValidate, + purchaseAmountValidate, + winningNumbersValidate, +} from "./inputValidator.js"; + +export async function getValidatedPurchaseAmount() { + while (true) { + const purchaseAmount = await Console.readLineAsync( + "구입 금액을 입력해 주세요.\n" + ); + + try { + purchaseAmountValidate(purchaseAmount); + return purchaseAmount; + } catch (error) { + Console.print(error.message); + } + } +} + +export async function getValidatedWinningNumbers() { + while (true) { + const winningNumbers = await Console.readLineAsync( + "\n당첨 번호를 입력해 주세요.\n" + ); + + try { + winningNumbersValidate(winningNumbers); + return winningNumbers.split(",").map(Number); + } catch (error) { + Console.print(error.message); + } + } +} + +export async function getValidatedBonusNumber(winningNumbers) { + while (true) { + const bonusNumber = await Console.readLineAsync( + "\n보너스 번호를 입력해 주세요.\n" + ); + + try { + bonusNumberValidate(bonusNumber, winningNumbers); + return Number(bonusNumber); + } catch (error) { + Console.print(error.message); + } + } +} diff --git a/src/App.js b/src/App.js index 091aa0a5d..63a151e61 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,31 @@ +import { lotto } from "../constants/lottoConstants.js"; +import { + generateLottos, + printLottoResult, + printLottos, +} from "../modules/lottoService.js"; +import { + getValidatedBonusNumber, + getValidatedPurchaseAmount, + getValidatedWinningNumbers, +} from "../modules/userInput.js"; + class App { - async run() {} + async run() { + const purchaseAmount = await getValidatedPurchaseAmount(); + + const lottoCount = Math.floor(purchaseAmount / lotto.PRICE); + + const lottos = generateLottos(lottoCount); + + printLottos(lottos); + + const winningNumbers = await getValidatedWinningNumbers(); + + const bonusNumber = await getValidatedBonusNumber(winningNumbers); + + printLottoResult(lottos, winningNumbers, bonusNumber, purchaseAmount); + } } export default App; diff --git a/src/Lotto.js b/src/Lotto.js deleted file mode 100644 index cb0b1527e..000000000 --- a/src/Lotto.js +++ /dev/null @@ -1,18 +0,0 @@ -class Lotto { - #numbers; - - constructor(numbers) { - this.#validate(numbers); - this.#numbers = numbers; - } - - #validate(numbers) { - if (numbers.length !== 6) { - throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); - } - } - - // TODO: 추가 기능 구현 -} - -export default Lotto; diff --git a/utils/lottoUtils.js b/utils/lottoUtils.js new file mode 100644 index 000000000..1330540dd --- /dev/null +++ b/utils/lottoUtils.js @@ -0,0 +1,8 @@ +export function getMatchedCount(lottoNumbers, winningNumbers) { + return lottoNumbers.filter((number) => winningNumbers.includes(number)) + .length; +} + +export function calculateYield(totalPrize, purchaseAmount) { + return ((totalPrize / purchaseAmount) * 100).toFixed(1); +} diff --git a/utils/throwError.js b/utils/throwError.js new file mode 100644 index 000000000..98ca3a12c --- /dev/null +++ b/utils/throwError.js @@ -0,0 +1,3 @@ +export default function throwError(message) { + throw new Error(`[ERROR] ${message}`); +}