diff --git a/README.md b/README.md index b168a180..94e7ce65 100644 --- a/README.md +++ b/README.md @@ -1 +1,48 @@ # javascript-planetlotto-precourse +## 최종 테스트 (행성 로또) + +## 기능 요구 사항 + +로또 번호의 숫자 범위는 1~30까지이다. +1개의 로또를 발행할 때 중복되지 않는 5개의 숫자를 뽑는다. +당첨 번호 추첨 시 중복되지 않는 숫자 5개와 보너스 번호 1개를 뽑는다. +당첨은 1등부터 5등까지 있다. 당첨 기준과 금액은 아래와 같다. +1등: 5개 번호 일치 / 100,000,000원 +2등: 4개 번호 + 보너스 번호 일치 / 10,000,000원 +3등: 4개 번호 일치 / 1,500,000원 +4등: 3개 번호 일치 + 보너스 번호 일치 / 500,000원 +5등: 2개 번호 일치 + 보너스 번호 일치 / 5,000원 +로또 구입 금액을 입력하면 구입 금액에 해당하는 만큼 로또를 발행해야 한다. +로또 1장의 가격은 500원이다. +당첨 번호와 보너스 번호를 입력받는다. +사용자가 구매한 로또 번호와 당첨 번호를 비교하여 당첨 내역을 출력하고 로또 게임을 종료한다. +사용자가 잘못된 값을 입력할 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시키고 해당 메시지를 출력한 다음 해당 지점부터 다시 입력을 받는다. + +## 도전 과제 + +기본 요구 사항을 모두 충족한 후, 아래 중 하나를 선택하여 도전하세요. 단, 도전 과제 수행 여부와 관계없이 기본 기능은 반드시 작동해야 합니다. + +## 도전 방향 (최대한 4시 59분 끝까지 도전) + +## 1. 프리코스 기간 최종테스트 준비 도중에 연습했던 도전을 planetlotto로 추가 기능 구현 시도 + +(주제: 만약 이 로또 문제에 csv가 주어져서 문제가 나온다면? + 오픈 미션때 연습했던것처럼 실생활 같은 문제로 연결된다면?) + +복권 판매점에서 복권을 판매하고 당첨 확인 및 수익률 통계를 관리하는 시스템을 만들어야 한다고 생각해보자. +그럴 경우 기능을 확장시켜야한다. 이 아이디어는 7기 최종 코딩 테스트를 문제를 프리코스 기간중에 풀어보고 직접 만들어 본 케이스이다. + +csv 를 받아야 하므로 LotteryShop는 import fs from "fs"; 로 시작한다. 마찬가지로 로또는 const numbers = Random pickUniqueNumbersInRange(1, 30, 5); 로 생산한다고 가정한다. + +csv는 간단하게 3개정도만 쉬는시간에 생각해본다. +type,name,price,firstPrize,secondPrize,thirdPrize,stock +LOTTO,로또6/45,1000,2000000000,30000000,1500000,100 +PENSION,연금복권,1000,700000000,100000000,10000000,80 +INSTANT,즉석복권,2000,10000000,1000000,100000,150 + +(App이랑 연결은 온라인 test 를 고려하여 로컬 npm test로 통과 되는지만 확인 후, 연결 중단) + +--- + +2. 최대한 여러 검증을 추가하는 방향으로 진행을 할것. +3. 매직 넘버를 최대한 줄여본다. +4. 시간이 될 경우. 로또 이외에도 연금복권이랑 즉석복권까지 만들어본다. \ No newline at end of file diff --git a/src/App.js b/src/App.js index 091aa0a5..f2155af1 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,154 @@ +import { Random } from "@woowacourse/mission-utils"; +import { InputView, OutputView } from "./view.js"; +import Lotto from "./Lotto.js"; +import { LOTTO_CONFIG, ERROR_MESSAGE } from "./constants.js"; + class App { - async run() {} + async run() { + const amount = await this.#askAmount(); + const lottoLists = this.#generateLottos(amount); + OutputView.printPurchasedLottos(lottoLists.length); + lottoLists.forEach((lotto) => OutputView.printLottos(lotto.numberCheck())); + + const winningNumbers = await this.#askWinningLotto(); + const bonusNumber = await this.#askBonusNumber(winningNumbers); + + const result = this.#resultCalculate( + lottoLists, + winningNumbers, + bonusNumber + ); + OutputView.printResult(result); + } + + async #askAmount() { + try { + const input = await InputView.askAmount(); + const amount = Number(input); + this.#validateAmount(amount); + return amount; + } catch (error) { + OutputView.printErrorMessage(error.message); + return this.#askAmount(); + } + } + + #validateAmount(amount) { + if (Number.isNaN(amount)) { + throw new Error(ERROR_MESSAGE.INVALID_NUMBER_FORMAT); + } + if (amount < LOTTO_CONFIG.PRICE) { + throw new Error(`[ERROR] ${LOTTO_CONFIG.PRICE}원 이상 입력해주세요.`); + } + if (amount % LOTTO_CONFIG.PRICE !== 0) { + throw new Error(`[ERROR] ${LOTTO_CONFIG.PRICE}원 단위로 입력해주세요.`); + } + } + + async #askWinningLotto() { + try { + const input = await InputView.askWinningLotto(); + const numbers = input.replaceAll(" ", "").split(",").map(Number); + this.#winningNumbersValidate(numbers); + return numbers; + } catch (error) { + OutputView.printErrorMessage(error.message); + return this.#askWinningLotto(); + } + } + + #winningNumbersValidate(numbers) { + if (numbers.some(Number.isNaN)) { + throw new Error(ERROR_MESSAGE.INVALID_NUMBER_FORMAT); + } + if (numbers.length !== LOTTO_CONFIG.NUMBER_COUNT) { + throw new Error( + `[ERROR] 로또 번호는 ${LOTTO_CONFIG.NUMBER_COUNT}개여야 합니다.` + ); + } + if (new Set(numbers).size !== numbers.length) { + throw new Error(ERROR_MESSAGE.DUPLICATE_TARGET_NUMBER); + } + const 범위확인 = numbers.every( + (num) => num >= LOTTO_CONFIG.MIN_NUMBER && num <= LOTTO_CONFIG.MAX_NUMBER + ); + if (!범위확인) { + throw new Error( + `[ERROR] 로또 번호는 ${LOTTO_CONFIG.MIN_NUMBER}부터 ${LOTTO_CONFIG.MAX_NUMBER} 사이의 숫자여야 합니다.` + ); + } + } + + async #askBonusNumber(winningNumbers) { + try { + const input = await InputView.askBonusNumber(); + const number = Number(input); + this.#bonusNumberValidate(number, winningNumbers); + return number; + } catch (error) { + OutputView.printErrorMessage(error.message); + return this.#askBonusNumber(winningNumbers); + } + } + + #bonusNumberValidate(number, winningNumbers) { + if (Number.isNaN(number)) { + throw new Error(ERROR_MESSAGE.INVALID_NUMBER_FORMAT); + } + if (number < LOTTO_CONFIG.MIN_NUMBER || number > LOTTO_CONFIG.MAX_NUMBER) { + throw new Error( + `[ERROR] 로또 번호는 ${LOTTO_CONFIG.MIN_NUMBER}부터 ${LOTTO_CONFIG.MAX_NUMBER} 사이의 숫자여야 합니다.` + ); + } + if (winningNumbers.includes(number)) { + throw new Error(ERROR_MESSAGE.DUPLICATE_BONUS_NUMBER); + } + } + + #generateLottos(amount) { + const count = amount / LOTTO_CONFIG.PRICE; + const lottoLists = []; + + for (let i = 0; i < count; i++) { + const numbers = Random.pickUniqueNumbersInRange( + LOTTO_CONFIG.MIN_NUMBER, + LOTTO_CONFIG.MAX_NUMBER, + LOTTO_CONFIG.NUMBER_COUNT + ); + lottoLists.push(new Lotto(numbers)); + } + + return lottoLists; + } + + #rankCalculate(matchCount, hasBonus) { + if (matchCount === 5) return 1; + if (matchCount === 4 && hasBonus) return 2; + if (matchCount === 4) return 3; + if (matchCount === 3 && hasBonus) return 4; + if (matchCount === 2 && hasBonus) return 5; + return 0; + } + + #resultCalculate(lottoLists, winningNumbers, bonusNumber) { + const result = new Map([ + [0, 0], + [1, 0], + [2, 0], + [3, 0], + [4, 0], + [5, 0], + ]); + + for (const lotto of lottoLists) { + const matchCount = lotto.matchCount(winningNumbers); + const hasBonus = lotto.hasBonus(bonusNumber); + const rank = this.#rankCalculate(matchCount, hasBonus); + result.set(rank, result.get(rank) + 1); + } + + return result; + } } export default App; diff --git a/src/LotteryShop.js b/src/LotteryShop.js new file mode 100644 index 00000000..79c33be4 --- /dev/null +++ b/src/LotteryShop.js @@ -0,0 +1,110 @@ +import fs from "fs"; +import { Random } from "@woowacourse/mission-utils"; +import { LOTTERY_TYPES, PLUS_ERROR_MESSAGE } from "./constants.js"; +import LottoLottery from "./LottoLottery.js"; +import PensionLottery from "./PensionLottery.js"; + +class LotteryShop { + #lotteryTypes; + #purchaseHistory; + #salesStats; + constructor(csvPath) { + this.#lotteryTypes = this.#readCSV(csvPath); + this.#purchaseHistory = {}; + this.#salesStats = {}; + + Object.keys(LOTTERY_TYPES).forEach((type) => { + this.#purchaseHistory[type] = []; + this.#salesStats[type] = { count: 0, revenue: 0 }; + }); + } + + #readCSV(filePath) { + const content = fs.readFileSync(filePath, "utf-8"); + const lines = content.trim().split("\n"); + const headers = lines[0].split(",").map((h) => h.trim()); + + const types = {}; + lines.slice(1).forEach((line) => { + if (line.trim() === "") return; + + const values = line.split(",").map((v) => v.trim()); + const obj = {}; + headers.forEach((header, index) => { + obj[header] = values[index]; + }); + types[obj.type] = obj; + }); + + return types; + } + + getLotteryInfo(type) { + return this.#lotteryTypes[type]; + } + + purchaseLotteries(type, amount) { + const info = this.#lotteryTypes[type]; + const price = Number(info.price); + const count = amount / price; + + if (Number(info.stock) < count) { + throw new Error(PLUS_ERROR_MESSAGE.SHORTAGE); + } + + const lotteries = []; + for (let i = 0; i < count; i++) { + lotteries.push(this.#generateLottery(type)); + } + + info.stock = String(Number(info.stock) - count); + + this.#purchaseHistory[type].push(...lotteries); + + this.#salesStats[type].count += count; + this.#salesStats[type].revenue += amount; + + return lotteries; + } + + #generateLottery(type) { + if (type === LOTTERY_TYPES.LOTTO) { + const numbers = Random.pickUniqueNumbersInRange(1, 30, 5); + return new LottoLottery(numbers.sort((a, b) => a - b)); + } + + if (type === LOTTERY_TYPES.PENSION) { + const number = Random.pickNumberInRange(1, 1000000); + return new PensionLottery(number); + } + + throw new Error(PLUS_ERROR_MESSAGE.TYPE); + } + + getPurchasedLotteries(type) { + return this.#purchaseHistory[type]; + } + + getSalesStats() { + return this.#salesStats; + } + + getStock(type) { + return Number(this.#lotteryTypes[type].stock); + } + + addStock(type, quantity) { + const current = Number(this.#lotteryTypes[type].stock); + this.#lotteryTypes[type].stock = String(current + quantity); + } + + getAllStocks() { + const stocks = {}; + Object.keys(LOTTERY_TYPES).forEach((type) => { + stocks[type] = this.getStock(type); + }); + return stocks; + } +} + +export default LotteryShop; diff --git a/src/Lotto.js b/src/Lotto.js new file mode 100644 index 00000000..a6693481 --- /dev/null +++ b/src/Lotto.js @@ -0,0 +1,45 @@ +import { LOTTO_CONFIG } from "./constants.js"; + +class Lotto { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = numbers; + } + + #validate(numbers) { + if (numbers.length !== LOTTO_CONFIG.NUMBER_COUNT) { + throw new Error( + `[ERROR] 로또 번호는 ${LOTTO_CONFIG.NUMBER_COUNT}개여야 합니다.` + ); + } + + if (new Set(numbers).size !== numbers.length) { + throw new Error("[ERROR] 로또 번호는 중복될 수 없습니다."); + } + + const scopeCheck = numbers.every( + (num) => num >= LOTTO_CONFIG.MIN_NUMBER && num <= LOTTO_CONFIG.MAX_NUMBER + ); + if (!scopeCheck) { + throw new Error( + `[ERROR] 로또 번호는 ${LOTTO_CONFIG.MIN_NUMBER}부터 ${LOTTO_CONFIG.MAX_NUMBER} 사이의 숫자여야 합니다.` + ); + } + } + + numberCheck() { + return this.#numbers; + } + + matchCount(winningNumbers) { + return this.#numbers.filter((num) => winningNumbers.includes(num)).length; + } + + hasBonus(bonusNumber) { + return this.#numbers.includes(bonusNumber); + } +} + +export default Lotto; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 00000000..5264bee3 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,43 @@ +export const LOTTO_CONFIG = Object.freeze({ + PRICE: 500, + NUMBER_COUNT: 5, + MIN_NUMBER: 1, + MAX_NUMBER: 30, +}); + +export const MATCH_COUNT = Object.freeze({ + FIRST: 6, + SECOND: 5, + THIRD: 5, + FOURTH: 4, + FIFTH: 3, +}); + +export const PRIZE_MONEY = Object.freeze({ + FIRST: 2000000000, + SECOND: 30000000, + THIRD: 1500000, + FOURTH: 50000, + FIFTH: 5000, +}); + +export const ERROR_MESSAGE = Object.freeze({ + INVALID_LOTTO_COUNT: "[ERROR] 로또 번호는 6개여야 합니다.", + DUPLICATE_LOTTO_NUMBER: "[ERROR] 로또 번호에 중복된 숫자가 있습니다.", + INVALID_NUMBER_FORMAT: "[ERROR] 로또 번호는 숫자여야 합니다.", + DUPLICATE_TARGET_NUMBER: "[ERROR] 당첨 번호에 중복된 숫자가 있습니다.", + INVALID_BONUS_FORMAT: "[ERROR] 보너스 번호는 숫자여야 합니다.", + DUPLICATE_BONUS_NUMBER: + "[ERROR] 보너스 번호는 당첨 번호와 중복될 수 없습니다.", +}); + +export const LOTTERY_TYPES = Object.freeze({ + LOTTO: "LOTTO", + PENSION: "PENSION", + INSTANT: "INSTANT", +}); + +export const PLUS_ERROR_MESSAGE = Object.freeze({ + SHORTAGE: "[ERROR] 재고가 부족합니다.", + TYPE: "[ERROR] 알 수 없는 타입입니다.", +}); diff --git a/src/lottery_types.csv b/src/lottery_types.csv new file mode 100644 index 00000000..6daaeee0 --- /dev/null +++ b/src/lottery_types.csv @@ -0,0 +1,4 @@ +type,name,price,firstPrize,secondPrize,thirdPrize,stock +LOTTO,로또6/45,1000,2000000000,30000000,1500000,100 +PENSION,연금복권,1000,700000000,100000000,10000000,80 +INSTANT,즉석복권,2000,10000000,1000000,100000,150 diff --git a/src/view.js b/src/view.js index ae6afd9c..51cc2cff 100644 --- a/src/view.js +++ b/src/view.js @@ -1,89 +1,58 @@ import { MissionUtils } from "@woowacourse/mission-utils"; - const InputView = { - /** - * @returns {number} - */ async askAmount() { - const input = await MissionUtils.Console.readLineAsync('구입금액을 입력해 주세요.\n'); - const num = parseInt(input, 10); - if (Number.isNaN(num)) { - throw new Error('구매금액은 숫자여야 합니다.'); - } - return num; + const input = await MissionUtils.Console.readLineAsync( + "구입금액을 입력해 주세요.\n" + ); + return input; }, - /** - * @returns {number[]} - */ async askWinningLotto() { - const input = await MissionUtils.Console.readLineAsync('지난 주 당첨 번호를 입력해 주세요.\n'); - - return input - .replaceAll(' ', '') - .split(',') - .map((s) => { - const n = parseInt(s, 10); - if (Number.isNaN(n)) { - throw new Error('당첨 번호는 숫자여야 합니다.'); - } - return n; - }); + const input = await MissionUtils.Console.readLineAsync( + "당첨 번호를 입력해 주세요.\n" + ); + return input; }, - /** - * @returns {number} - */ async askBonusNumber() { - const input = await MissionUtils.Console.readLineAsync('보너스 번호를 입력해 주세요.\n'); - const num = parseInt(input, 10); - if (Number.isNaN(num)) { - throw new Error('보너스 번호는 숫자여야 합니다.'); - } - return num; + const input = await MissionUtils.Console.readLineAsync( + "보너스 번호를 입력해 주세요.\n" + ); + return input; }, }; const OutputView = { - /** - * @param {number[][]} lottos - */ printPurchasedLottos(lottos) { - const lines = [ - `${lottos.length}개를 구매했습니다.`, - ...lottos.map(lotto => `[${lotto.join(', ')}]`), - ]; - MissionUtils.Console.print(lines.join('\n')); + MissionUtils.Console.print(`${lottos}개를 구매했습니다.`); }, - /** - * index 0번은 미당첨 - * index 1~5번은 1~5등 - * - * @param {Map} countsByRank - */ - printResult(countsByRank) { - const getCount = (k) => countsByRank.get(k) ?? 0; - - const output = [ - '당첨 통계', - '---', - `5개 일치 (100,000,000원) - ${getCount(1)}개`, - `4개 일치, 보너스 번호 일치 (10,000,000원) - ${getCount(2)}개`, - `4개 일치 (1,500,000원) - ${getCount(3)}개`, - `3개 일치, 보너스 번호 일치 (500,000원) - ${getCount(4)}개`, - `2개 일치, 보너스 번호 일치 (5,000원) - ${getCount(5)}개`, - `0개 일치 (0원) - ${getCount(0)}개`, - ].join('\n'); + printLottos(lottos) { + const lines = [...lottos].sort((a, b) => a - b); + MissionUtils.Console.print(`[${lines.join(", ")}]`); + }, - MissionUtils.Console.print(output); + printResult(countByRank) { + const getCount = (k) => countByRank.get(k) ?? 0; + + MissionUtils.Console.print("당첨 통계"); + MissionUtils.Console.print("---"); + MissionUtils.Console.print(`5개 일치 (100,000,000원) - ${getCount(1)}개`); + MissionUtils.Console.print( + `4개 일치, 보너스 번호 일치 (10,000,000원) - ${getCount(2)}개` + ); + MissionUtils.Console.print(`4개 일치 (1,500,000원) - ${getCount(3)}개`); + MissionUtils.Console.print( + `3개 일치, 보너스 번호 일치 (500,000원) - ${getCount(4)}개` + ); + MissionUtils.Console.print( + `2개 일치, 보너스 번호 일치 (5,000원) - ${getCount(5)}개` + ); + MissionUtils.Console.print(`0개 일치 (0원) - ${getCount(0)}개`); }, - /** - * @param {string} message - */ printErrorMessage(message) { - MissionUtils.Console.print(`[ERROR] ${message}`); + MissionUtils.Console.print(message); }, };