diff --git a/README.md b/README.md index 15bb106b5..8910d87ed 100644 --- a/README.md +++ b/README.md @@ -1 +1,37 @@ # javascript-lotto-precourse + +## 기능 목록 - turtlehwan + +- [x] 로또 구입 금액 입력 기능 + - [x] 예외 : 정수가 아닌 숫자입니다. + - [x] 예외 : 정수가 1,000 미만입니다. + - [x] 예외 : 입력 받은 수가 1,000 단위가 아닙니다. +- [x] 로또 번호 발행 기능 + - [x] 1~45 정수 숫자 범위 -> MissionUtils + - [x] 중복되지 않는 6개 숫자 발행 -> MissionUtils +- [x] 발행한 로또 수량 및 번호 출력 (오름차순) +- [x] 당첨 번호 입력 기능 + - [x] 양의 정수 판별 + - [x] 1~45 숫자 범위 + - [x] 중복되지 않는 6개의 숫자 입력 +- [x] 보너스 번호 입력 기능 + - [x] 양의 정수 판별 + - [x] 1~45 숫자 범위 + - [x] 중복되지 않는 6개의 숫자 입력 +- [x] 당첨 확인(등수 판별) 기능 +- [x] 당첨 내역 확인 및 출력 +- [x] 수익률 계산 및 출력 + - [x] 소수점 둘째 자리에서 반올림 + +## 중점 사항 + +- 값을 하드코딩하지 않고, 상수로 빼내서 정리하기 +- 메서드가 한 가지 기능을 하는지 확인하는 기준 세우기 + - 여러 메서드에서 중복되는 코드가 있으면 별도의 메서드로 분리 + - 해당 메서드를 변경할 때 서로 다른 이유로 변경하고 commit하는지 살펴보기 + - 안내 문구 출력, 사용자 입력 처리, 유효값 검증 등의 작업을 각기 다른 함수로 분리 +- class의 private 필드 잘 이용하기 (# 문법) +- JavaScript에서 객체를 만드는 다양한 방법을 이해하고 사용하기 +- 기능별 단위 테스트 만들기 + - 테스트를 작성하는 이유에 대해 본인의 경험을 토대로 정리 + - 처음부터 큰 단위의 테스트를 만들지 않기 diff --git a/__tests__/LottoVendingMachineTest.js b/__tests__/LottoVendingMachineTest.js new file mode 100644 index 000000000..3b3ec7b62 --- /dev/null +++ b/__tests__/LottoVendingMachineTest.js @@ -0,0 +1,51 @@ +import { MissionUtils } from "@woowacourse/mission-utils"; +import LottoVendingMachine from "../src/LottoVendingMachine.js"; + +const mockQuestions = (inputs) => { + MissionUtils.Console.readLineAsync = jest.fn(); + + MissionUtils.Console.readLineAsync.mockImplementation(() => { + const input = inputs.shift(); + + return Promise.resolve(input); + }); +}; + +const getLogSpy = () => { + const logSpy = jest.spyOn(MissionUtils.Console, "print"); + logSpy.mockClear(); + return logSpy; +}; + +const runException = async (input) => { + // given + const logSpy = getLogSpy(); + + const INPUT_NUMBERS_TO_END = ["1000", "1,2,3,4,5,6", "7"]; + mockQuestions([input, ...INPUT_NUMBERS_TO_END]); + + // when + const LottoVM = new LottoVendingMachine(); + await LottoVM.purchaseLottoAmountInput(); + + // then + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[ERROR]")); +}; + +describe("LottoVendingMachine 클래스 테스트", () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + test("금액이 정수가 아니면 예외가 발생한다.", async () => { + await runException("1000.5"); + }); + + test("금액이 1000원 미만이면 예외가 발생한다.", async () => { + await runException("500"); + }); + + test("금액이 1000원 단위가 아니면 예외가 발생한다.", async () => { + await runException("1521"); + }); +}); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..cf10a04e9 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,57 @@ +import babelParser from '@babel/eslint-parser'; +import { FlatCompat } from '@eslint/eslintrc'; +import pluginJs from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import globals from 'globals'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +const compat = new FlatCompat({ + baseDirectory: dirname, +}); + +export default [ + { + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parser: babelParser, + parserOptions: { + requireConfigFile: false, + }, + globals: { + ...globals.node, + ...globals.jest, + }, + }, + }, + ...compat.extends('eslint-config-airbnb-base'), + prettier, + { + files: ['/src//.js', '/tests//.js', '/tests//*.js'], + rules: { + 'max-depth': ['error', 2], + 'max-params': ['error', 3], + 'max-lines-per-function': ['error', { max: 15 }], + 'import/extensions': ['error', 'ignorePackages'], + 'import/prefer-default-export': 'off', + 'class-methods-use-this': 'off', + 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], + 'no-console': 'off', + 'no-unused-vars': 'error', + 'prefer-const': 'error', + 'no-var': 'error', + eqeqeq: ['error', 'always'], + }, + }, + { + files: ['eslint.config.js'], + rules: { + 'no-underscore-dangle': 'off', + 'import/no-extraneous-dependencies': ['error', { packageDir: __dirname }], + }, + }, +]; \ No newline at end of file diff --git a/src/App.js b/src/App.js index 091aa0a5d..c1672118a 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,10 @@ +import LottoVendingMachine from "./LottoVendingMachine.js"; + class App { - async run() {} + async run() { + const LottoVM = new LottoVendingMachine(); + await LottoVM.run(); + } } export default App; diff --git a/src/Lotto.js b/src/Lotto.js index cb0b1527e..ae8820a4f 100644 --- a/src/Lotto.js +++ b/src/Lotto.js @@ -1,18 +1,53 @@ +import { MAGIC_NUMBER } from "./constants/magicNumber.js"; +import { MESSAGES } from "./constants/messages.js"; + class Lotto { #numbers; constructor(numbers) { this.#validate(numbers); + this.#validateDuplicate(numbers); this.#numbers = numbers; } #validate(numbers) { if (numbers.length !== 6) { - throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); + throw new Error( + MESSAGES.ERROR.PREFIX + + MESSAGES.ERROR.LOTTO_PICK_NUM(MAGIC_NUMBER.LOTTO_PICK_NUM) + ); + } + + numbers.forEach((number) => { + if (!Number.isInteger(Number(number))) { + throw new Error(MESSAGES.ERROR.PREFIX + MESSAGES.ERROR.NOT_INT); + } + this.#validateIntsRange( + number, + MAGIC_NUMBER.LOTTO_MIN_NUM, + MAGIC_NUMBER.LOTTO_MAX_NUM + ); + }); + } + + #validateIntsRange(number, min, max) { + if (Number(number) < min || Number(number) > max) { + throw new Error( + MESSAGES.ERROR.PREFIX + MESSAGES.ERROR.RANGE_INT(min, max) + ); } } - // TODO: 추가 기능 구현 + #validateDuplicate(numbers) { + const set = new Set(numbers); + if (numbers.length !== set.size) { + throw new Error(MESSAGES.ERROR.PREFIX + MESSAGES.ERROR.DUPLICATE_INT); + } + } + + getLottoNumbers() { + return this.#numbers; + } } export default Lotto; diff --git a/src/LottoVendingMachine.js b/src/LottoVendingMachine.js new file mode 100644 index 000000000..58df6c321 --- /dev/null +++ b/src/LottoVendingMachine.js @@ -0,0 +1,191 @@ +import { Console, MissionUtils } from "@woowacourse/mission-utils"; +import { MESSAGES } from "./constants/messages.js"; +import { MAGIC_NUMBER } from "./constants/magicNumber.js"; +import Lotto from "./Lotto.js"; + +class LottoVendingMachine { + #lottoAmount = 0; + #lottos = []; + #winNumbers = []; + #bounsNumber = 0; + #winCount = {}; + + async run() { + await this.purchaseLottoAmountInput(); + + this.issueLottos(); + + this.printIssueLottosInfo(); + + await this.winNumbersInput(); + + await this.bounsNumberInput(); + + // 당첨내역 출력() + this.printWinInfo(); + // 총 수익률 출력() + } + + async purchaseLottoAmountInput() { + while (true) { + try { + let money = await Console.readLineAsync( + MESSAGES.INPUT.PURCHASE_LOTTO_MONEY + ); + money = this.#validateLottoAmount(money); + this.#lottoAmount = money / 1000; + + return this.#lottoAmount; + } catch (e) { + Console.print(e.message); + } + } + } + + #validateLottoAmount(number) { + if (!Number.isInteger(Number(number))) { + throw new Error(MESSAGES.ERROR.PREFIX + MESSAGES.ERROR.NOT_INT); + } + if (Number(number) < 1000) { + throw new Error(MESSAGES.ERROR.PREFIX + MESSAGES.ERROR.SMALL_INT); + } + if (number.slice(-3) !== "000") { + throw new Error(MESSAGES.ERROR.PREFIX + MESSAGES.ERROR.NOT_UNIT_INT); + } + + return Number(number); + } + + issueLottos() { + while (this.#lottos.length < this.#lottoAmount) { + this.#lottos.push(this.#pickRandomLotto(MAGIC_NUMBER.LOTTO_PICK_NUM)); + } + } + + #pickRandomLotto(pickNum) { + return MissionUtils.Random.pickUniqueNumbersInRange( + MAGIC_NUMBER.LOTTO_MIN_NUM, + MAGIC_NUMBER.LOTTO_MAX_NUM, + pickNum + ); + } + + printIssueLottosInfo() { + Console.print(MESSAGES.OUTPUT.PURCHASE_LOTTO_NUMBER(this.#lottoAmount)); + this.#lottos.forEach((lotto) => Console.print(`[${lotto.join(", ")}]`)); + } + + async winNumbersInput() { + while (true) { + try { + Console.print(""); + let winNumbers = await Console.readLineAsync( + MESSAGES.INPUT.WIN_NUMBERS + ); + winNumbers = winNumbers.split(",").map((n) => Number(n)); + const lotto = new Lotto(winNumbers); + this.#winNumbers = lotto.getLottoNumbers(); + + return this.#winNumbers; + } catch (e) { + Console.print(e.message); + } + } + } + + async bounsNumberInput() { + while (true) { + try { + Console.print(""); + let bonusNumber = await Console.readLineAsync( + MESSAGES.INPUT.BOUNS_NUMBER + ); + + if (!Number.isInteger(Number(bonusNumber))) { + throw new Error(MESSAGES.ERROR.PREFIX + MESSAGES.ERROR.NOT_INT); + } + this.#validateIntsRange( + bonusNumber, + MAGIC_NUMBER.LOTTO_MIN_NUM, + MAGIC_NUMBER.LOTTO_MAX_NUM + ); + + if (this.#winNumbers.includes(Number(bonusNumber))) { + throw new Error(MESSAGES.ERROR.PREFIX + MESSAGES.ERROR.DUPLICATE_INT); + } + this.#bounsNumber = bonusNumber; + + return this.#bounsNumber; + } catch (e) { + Console.print(e.message); + } + } + } + + #validateIntsRange(number, min, max) { + if (Number(number) < min || Number(number) > max) { + throw new Error( + MESSAGES.ERROR.PREFIX + MESSAGES.ERROR.RANGE_INT(min, max) + ); + } + } + + printWinInfo() { + const winCount = this.#calcWin(); + + Console.print(""); + Console.print(MESSAGES.OUTPUT.WIN_RATE); + Console.print(MESSAGES.OUTPUT.WIN_DETAIL_5th(winCount["3"] ?? 0)); + Console.print(MESSAGES.OUTPUT.WIN_DETAIL_4th(winCount["4"] ?? 0)); + Console.print(MESSAGES.OUTPUT.WIN_DETAIL_3rd(winCount["5"] ?? 0)); + Console.print(MESSAGES.OUTPUT.WIN_DETAIL_2nd(winCount["5.1"] ?? 0)); + Console.print(MESSAGES.OUTPUT.WIN_DETAIL_1st(winCount["6"] ?? 0)); + Console.print(MESSAGES.OUTPUT.PROFIT_RATE(this.#calcProfitRate())); + } + + #calcWin() { + const hasSameNumArray = this.#lottos.map((lotto) => { + const sameNum = lotto.reduce( + (acc, cur) => acc + Number(this.#winNumbers.includes(cur)), + 0 + ); + + if (sameNum === 5 && lotto.includes(this.#bounsNumber)) { + return 5.1; + } + return sameNum; + }); + + const winCount = {}; + + hasSameNumArray.forEach((num) => { + winCount[num] = (winCount[num] ?? 0) + 1; + }); + + this.#winCount = winCount; + return winCount; + } + + #calcProfitRate() { + const winCount = this.#winCount; + const totalPrize = + (winCount["3"] ?? 0) * 5000 + + (winCount["4"] ?? 0) * 50000 + + (winCount["5"] ?? 0) * 1500000 + + (winCount["5.1"] ?? 0) * 30000000 + + (winCount["6"] ?? 0) * 2000000000; + const totalMoney = this.#lottoAmount * 1000; + + return this.#formatProfitRate( + Math.round((totalPrize / totalMoney) * 10000) / 100 + ); + } + + #formatProfitRate(rate) { + const parts = rate.toFixed(1).split("."); + const integerPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return `${integerPart}.${parts[1]}`; + } +} + +export default LottoVendingMachine; diff --git a/src/constants/magicNumber.js b/src/constants/magicNumber.js new file mode 100644 index 000000000..8728c533e --- /dev/null +++ b/src/constants/magicNumber.js @@ -0,0 +1,6 @@ +export const MAGIC_NUMBER = Object.freeze({ + LOTTO_MIN_NUM: 1, + LOTTO_MAX_NUM: 45, + LOTTO_PICK_NUM: 6, + LOTTO_BONUS_NUM_COUNT: 1, +}); diff --git a/src/constants/messages.js b/src/constants/messages.js new file mode 100644 index 000000000..40e0e2a5e --- /dev/null +++ b/src/constants/messages.js @@ -0,0 +1,27 @@ +export const MESSAGES = Object.freeze({ + INPUT: { + PURCHASE_LOTTO_MONEY: "구입금액을 입력해 주세요.\n", + WIN_NUMBERS: "당첨 번호를 입력해 주세요.\n", + BOUNS_NUMBER: "보너스 번호를 입력해 주세요.\n", + }, + OUTPUT: { + PURCHASE_LOTTO_NUMBER: (n) => `${n}개를 구매했습니다.`, + WIN_RATE: "당첨 통계\n---", + WIN_DETAIL_5th: (n) => `3개 일치 (5,000원) - ${n}개`, + WIN_DETAIL_4th: (n) => `4개 일치 (50,000원) - ${n}개`, + WIN_DETAIL_3rd: (n) => `5개 일치 (1,500,000원) - ${n}개`, + WIN_DETAIL_2nd: (n) => `5개 일치, 보너스 볼 일치 (30,000,000원) - ${n}개`, + WIN_DETAIL_1st: (n) => `6개 일치 (2,000,000,000원) - ${n}개`, + PROFIT_RATE: (profitRate) => `총 수익률은 ${profitRate}%입니다.`, + }, + ERROR: { + PREFIX: `[ERROR] `, + NOT_INT: `정수가 아닌 숫자입니다.\n`, + SMALL_INT: `정수가 1,000 미만입니다.\n`, + NOT_UNIT_INT: `입력 받은 수가 1,000 단위가 아닙니다.\n`, + RANGE_INT: (min, max) => + `${min}에서 ${max} 사이의 정수를 입력해야 합니다.\n`, + LOTTO_PICK_NUM: (pickNum) => `로또 번호는 ${pickNum}개여야 합니다.\n`, + DUPLICATE_INT: `중복된 숫자가 있습니다.\n`, + }, +});