diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js index 97bd457659..ff0ddde87f 100644 --- a/__tests__/LottoTest.js +++ b/__tests__/LottoTest.js @@ -1,4 +1,4 @@ -import Lotto from "../src/Lotto.js"; +import Lotto from "../src/Model/Lotto.js"; describe("로또 클래스 테스트", () => { test("로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.", () => { diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000..c0f3383886 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,161 @@ +# 게임 흐름 +1. 로또 시작 +2. 로또를 구입하기 위해 지불할 금액 입력받기 + - 금액에 대한 유효성 검사 + - 금액을 로또 개수로 변환 후, 출력 +4. 로또 번호 생성 로직 + - 로또 개수만큼 + 1) 로또 번호의 숫자를 1~45까지 무작위로 6개를 생성 + 2) 로또 번호의 배열을 오름차순으로 정렬 후, 출력 +5. 당첨 번호 입력받기 + - 당첨 번호에 대한 유효성 검사 +6. 보너스 당첨 번호 입력받기 + - 보너스 번호에 대한 유효성 검사 +7. 로또 결과 + - 주어진 로또 번호가 당첨 번호와 일치하는지 확인 후, 출력 + - 수익률 계산 후, 출력 + + +
+
+ +# 구현할 기능 목록 + +# Controller +## LottoContoller.js +> 사용자 입력을 받아 모델을 업데이트하고, 뷰를 통해 결과를 표시하는 함수들을 관리 + +
+ +handlePurchase +- [x] 로또 구매를 처리 + +handleLottoWinningNumbers +- [x] 당첨 번호 입력을 처리 +- [x] 보너스 당첨 번호 입력을 처리 + +calculateProfitRates +- [x] 수익률을 계산 + +
+ +# Model +## Lotto.js +> 로또 번호를 생성하고 관리 + +
+ +generateLottoNumbers +- [x] 로또 번호의 숫자를 1~45까지 무작위로 6개를 생성 + +sortLottoNumbers +- [x] 로또 번호를 오름차순으로 정렬 + +
+ +## LottoMachine.js +> 로또 발행과 관련된 로직 관리 + +
+ +calculateLottoCount +- [x] 지불한 금액으로 구매할 수 있는 로또의 수를 계산 + +
+ +## WinningLotto.js +> 당첨 번호와 보너스 번호를 관리, 당첨 여부를 확인 + +
+ +calculateNumberOfMatchingNumbers +- [x] 로또에 있는 번호 중 당첨 번호와 일치하는 개수를 계산 + +isBonusNumberMatched +- [x] 보너스 번호가 로또 번호에 포함되어 있는지 확인 + +determinePrizeCategory +- [x] 일치하는 번호의 개수와 보너스 번호의 존재 여부에 따라 상금 등급을 결정 + +countAndPrintResult +- [x] 상금 등급에 따라 화면에 출력하도록 함 + +checkWinning +- [x] 주어진 로또 번호가 당첨 번호와 일치하는지 확인 + +
+ +# View +## InputView.js +> **사용자로부터 입력을 받는 부분을 담당**
+ 금액 입력, 당첨 번호 입력 + +
+ +promptPurchaseAmount +- [x] 사용자에게 구입 금액을 입력받기 +- `구입금액을 입력해 주세요.` +- 예외처리 + - [x] 값이 비어있는지 확인 + - [x] 숫자 형식인지 확인 + - [x] 1,000원 단위로 입력했는지 확인 + +promptWinnningNumbers +- [x] 사용자에게 당첨 번호(배열)를 입력받기 +- `당첨 번호를 입력해 주세요.` +- 예외처리 + - [x] 값이 비어있는지 확인 + - [x] 로또 번호 중복되어있는지 확인 + - [x] 로또 번호 1~45 사이인지 확인 + - [x] 6개 입력되어있는지 확인 + +promptBonusNumber +- [x] 사용자에게 보너스 번호(숫자)를 입력받기 +- `보너스 번호를 입력해 주세요.` +- 예외처리 + - [x] 값이 비어있는지 확인 + - [x] 숫자 형식인지 확인 + - [x] 로또 번호 1~45 사이인지 확인 + - [x] 로또 번호와 중복되는지 확인 + +
+ +## OutputView.js +> **결과나 메세지를 사용자에게 표시하는 부분을 담당**
+ 로또 번호, 당첨 결과, 수익률 등을 사용자에게 표시하는 함수들을 관리 + +
+ +printLottoCounts +- [x] 구매한 로또 개수를 화면에 출력하기 +- `${lottoCount}개를 구매했습니다.` + +printLottos +- [x] 구매한 로또 번호를 화면에 출력하기 +- `[8, 21, 23, 41, 42, 43]`
+ `[3, 5, 11, 16, 32, 38]` ... + +printLottoResult +- [x] 당첨 결과를 화면에 출력하기 +- `당첨 통계`
+ `---`
+ `3개 일치 (5,000원) - 1개`
+ `4개 일치 (50,000원) - 0개`
+ `5개 일치 (1,500,000원) - 0개`
+ `5개 일치, 보너스 볼 일치 (30,000,000원) - 0개`
+ `6개 일치 (2,000,000,000원) - 0개`
+ +printProfitRates +- [x] 수익률을 화면에 출력하기 +- `총 수익률은 ${winningRates}%입니다.` + +
+ +# Utils +## Constants.js +> Enum 관리(게임 메세지, 에러 메세지, 각 등수별 상금 정보) + +
+ +## InputValidator.js +> 입력값이 유효한지 검사하는 로직을 관리 \ No newline at end of file diff --git a/src/App.js b/src/App.js index c38b30d5b2..8f97e044f6 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,38 @@ +import OutputView from "./View/OutputView.js"; +import LottoController from "./Controller/LottoController.js"; +import LottoMachine from "./Model/LottoMachine.js"; +import WinningLotto from "./Model/WinningLotto.js"; + class App { - async play() {} + constructor() { + this.outputView = new OutputView(); + this.lottoMachine = new LottoMachine(); + this.winningLotto = new WinningLotto(); + this.lottoController = new LottoController(); + } + + async play() { + await this.startLotto(); + } + + async startLotto() { + const numberOfLottos = await this.lottoController.handlePurchase(); + this.outputView.printLottoCounts(numberOfLottos); + + const lottoNumbersArray = this.lottoMachine.generateLottoNumbers(numberOfLottos); + this.outputView.printLottoNumbers(lottoNumbersArray); + + const winningNumbers = await this.lottoController.handleLottoWinningNumbers(); + + const lottoResult = lottoNumbersArray.map(lottoNumbers => { + return this.winningLotto.checkWinning(lottoNumbers, winningNumbers); + }) + + const lottoWinningResult = this.winningLotto.countAndPrintResult(lottoResult); + + const profitRates = this.lottoController.calculateProfitRates(lottoWinningResult); + this.outputView.printProfitRates(profitRates.toFixed(1)); + } } export default App; diff --git a/src/Controller/LottoController.js b/src/Controller/LottoController.js new file mode 100644 index 0000000000..59cd893e75 --- /dev/null +++ b/src/Controller/LottoController.js @@ -0,0 +1,45 @@ +import InputView from "../View/InputView.js"; +import LottoMachine from "../Model/LottoMachine.js"; +import { RANK_PRIZE } from "../utils/Constants.js"; + +class LottoController { + constructor() { + this.purchaseAmount = 0; + this.inputView = new InputView(); + this.lottoMachine = new LottoMachine(); + } + + async handlePurchase() { + this.purchaseAmount = await this.inputView.promptPurchaseAmount(); + const numberOfLottos = this.lottoMachine.calculateLottoCount(this.purchaseAmount); + + return numberOfLottos; + } + + async handleLottoWinningNumbers() { + const winningNumbers = await this.inputView.promptWinningNumbers(); + const bonusNumbers = await this.inputView.promptBonusNumber(); + + return [winningNumbers, bonusNumbers]; + } + + convertPrizeToNumber(prize) { + return parseInt(prize.replace(/[^0-9]/g, '')); + } + + calculateTotalPrize(lottoResult) { + return lottoResult.reduce((total, count, rank) => { + if (rank === 0 || !RANK_PRIZE[rank]) return total; + + const prize = this.convertPrizeToNumber(RANK_PRIZE[rank]); + return total + (prize * count); + }, 0); + } + + calculateProfitRates(lottoResult) { + const totalPrize = this.calculateTotalPrize(lottoResult); + return (totalPrize / this.purchaseAmount) * 100; + } +} + +export default LottoController; \ No newline at end of file diff --git a/src/Lotto.js b/src/Lotto.js deleted file mode 100644 index cb0b1527e9..0000000000 --- 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/src/Model/Lotto.js b/src/Model/Lotto.js new file mode 100644 index 0000000000..17cd5694c7 --- /dev/null +++ b/src/Model/Lotto.js @@ -0,0 +1,20 @@ +import InputValidator from '../utils/InputValidator.js'; + +class Lotto { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = numbers; + } + + #validate(numbers) { + InputValidator.validateLottoWinningNumber(numbers); + } + + getLottoNumbers() { + return this.#numbers.sort((num1, num2) => num1 - num2); + } +} + +export default Lotto; diff --git a/src/Model/LottoMachine.js b/src/Model/LottoMachine.js new file mode 100644 index 0000000000..c18cd9f9c8 --- /dev/null +++ b/src/Model/LottoMachine.js @@ -0,0 +1,28 @@ +import { Random } from '@woowacourse/mission-utils'; +import OutputView from "../View/OutputView.js"; +import Lotto from './Lotto.js'; + +class LottoMachine { + constructor() { + this.lottoNumbersArray = []; + this.outputView = new OutputView(); + } + + calculateLottoCount(amount) { + return Math.floor(amount / 1000); + } + + generateLottoNumbers(lottoCount) { + for (let i = 0; i < lottoCount; i += 1) { + const lottoNumbers = Random.pickUniqueNumbersInRange(1, 45, 6); + const lottos = new Lotto(lottoNumbers); + const lottoSortNumbers = lottos.getLottoNumbers(lottoNumbers); + + this.lottoNumbersArray.push(lottoSortNumbers); + } + + return this.lottoNumbersArray; + } +} + +export default LottoMachine; diff --git a/src/Model/WinningLotto.js b/src/Model/WinningLotto.js new file mode 100644 index 0000000000..ca3ef84597 --- /dev/null +++ b/src/Model/WinningLotto.js @@ -0,0 +1,55 @@ +import OutputView from "../View/OutputView.js"; + +class WinningLotto { + constructor() { + this.outputView = new OutputView(); + } + + calculateNumberOfMatchingNumbers(lottoNumbers, winningNumbers) { + return lottoNumbers.filter((number) => winningNumbers.includes(number)).length; + } + + isBonusNumberMatched(lottoNumbers, bonusNumber) { + return lottoNumbers.includes(bonusNumber); + } + + determinePrizeCategory(matchCount, isBonusMatched) { + if (matchCount === 6) return 1; + if (matchCount === 5 && isBonusMatched) return 2; + if (matchCount === 5) return 3; + if (matchCount === 4) return 4; + if (matchCount === 3) return 5; + + return 0; + } + + checkWinning(lottoNumbers, winningNumbers) { + const winningNums = winningNumbers[0].map(Number); + const bonusNum = Number(winningNumbers[1]); + + const matchCount = this.calculateNumberOfMatchingNumbers(lottoNumbers, winningNums); + const isBonusMatched = this.isBonusNumberMatched(lottoNumbers, bonusNum); + + return this.determinePrizeCategory(matchCount, isBonusMatched); + } + + countAndPrintResult(lottoResult) { + const counts = {}; + + for (let i = 0; i<=5; i+= 1) { + counts[i] = 0; + } + + lottoResult.forEach(num => { + if (num >= 1 && num <= 5) { + counts[num] += 1; + } + }); + + this.outputView.printWinningResult(counts); + + return Object.values(counts); + } +} + +export default WinningLotto; \ No newline at end of file diff --git a/src/View/InputView.js b/src/View/InputView.js new file mode 100644 index 0000000000..8cf693a51c --- /dev/null +++ b/src/View/InputView.js @@ -0,0 +1,63 @@ +import { Console } from '@woowacourse/mission-utils'; +import { GAME_MESSAGES } from '../utils/Constants.js'; +import InputValidator from '../utils/InputValidator.js'; + +class InputView { + constructor() { + this.winningNumberArray = []; + } + + async getUserInput(question) { + return await Console.readLineAsync(question); + } + + async promptPurchaseAmount() { + while(true) { + try { + const purchaseAmount = await this.getUserInput(GAME_MESSAGES.ENTER_PURCHASE_AMOUNT); + + const userMoney = Number(purchaseAmount); + console.log('userMoney: ', userMoney); + InputValidator.validatePurchaseAmount(userMoney); + + return userMoney; + } catch(error) { + Console.print(error.message); + } + } + } + + async promptWinningNumbers() { + while(true) { + try { + const winningNumbers = await this.getUserInput(GAME_MESSAGES.ENTER_WINNING_NUMBERS); + const winningNumbersArray = winningNumbers.split(',').map((number) => number.trim()); + + this.winningNumberArray = winningNumbersArray; + + InputValidator.validateLottoWinningNumber(winningNumbersArray); + + return winningNumbersArray; + } catch(error) { + Console.print(error); + } + } + + } + + async promptBonusNumber() { + while(true){ + try { + const bonusNumber = await this.getUserInput(GAME_MESSAGES.ENTER_BONUS_NUMBER); + + InputValidator.validateLottoBonusNumber(this.winningNumberArray, Number(bonusNumber)); + + return bonusNumber; + } catch(error) { + Console.print(error); + } + } + } +} + +export default InputView; diff --git a/src/View/OutputView.js b/src/View/OutputView.js new file mode 100644 index 0000000000..4a09bec568 --- /dev/null +++ b/src/View/OutputView.js @@ -0,0 +1,34 @@ +import { Console } from '@woowacourse/mission-utils'; +import { GAME_MESSAGES, RANK_MESSAGES } from '../utils/Constants.js'; + +class OutputView { + constructor() {} + + printLottoCounts(lottoCounts) { + Console.print(lottoCounts + GAME_MESSAGES.CONFIRM_PURCHASED_LOTTOS_AMOUNT); + // Console.print(''); + } + + printLottoNumbers(lottos) { + lottos.map((lotto, _) => { + Console.print(`[${lotto.join(", ")}]`); + }) + Console.print(''); + } + + printWinningResult(counts) { + Console.print(GAME_MESSAGES.STATICS_HEADER); + Console.print(GAME_MESSAGES.DIVIDER); + + for (let rank = 5; rank >= 1; rank -= 1) { + const message = RANK_MESSAGES[rank] + `${counts[rank]}` + GAME_MESSAGES.MESSSAGE_SUFFIX; + Console.print(message); + } + } + + printProfitRates(profitRates) { + Console.print(GAME_MESSAGES.TOTAL_PRIZE_RATE(profitRates)); + } +} + +export default OutputView; \ No newline at end of file diff --git a/src/utils/Constants.js b/src/utils/Constants.js new file mode 100644 index 0000000000..5467e553f3 --- /dev/null +++ b/src/utils/Constants.js @@ -0,0 +1,38 @@ +export const GAME_MESSAGES = { + ENTER_PURCHASE_AMOUNT: "구입금액을 입력해 주세요. ", + CONFIRM_PURCHASED_LOTTOS_AMOUNT: "개를 구매했습니다.", + ENTER_WINNING_NUMBERS: "당첨 번호를 입력해 주세요. ", + ENTER_BONUS_NUMBER: "보너스 번호를 입력해 주세요. ", + TOTAL_PRIZE_RATE: (winningRates) => `총 수익률은 ${winningRates}%입니다.`, + STATICS_HEADER: "당첨 통계", + DIVIDER: "---------", + MESSSAGE_SUFFIX: "개" +} + +export const ERROR_MESSAGES = { + EMPTY_INPUT: '[ERROR] 값이 비어있습니다.', + INVALID_NUMBER_FORMAT: "[ERROR] 숫자 형식이 아닙니다.", + DUPLICATE_LOTTO_NUMBER: "[ERROR] 로또 번호가 중복되면 안됩니다.", + NUMBER_OUT_OF_RANGE: `[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.`, + BONUS_NUMBER_OUT_OF_RANGE: `[ERROR] 보너스 로또 번호도 1부터 45 사이의 숫자여야 합니다.`, + NUMBER_OUT_OF_RANGE: "[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.", + LOTTO_NUMBER_OUT_OF_RANGE: "[ERROR] 로또 번호는 6개를 입력해주세요.", + INVAID_PURCHASE_AMOUNT: "[ERROR] 구입 금액은 1,000원 단위로 입력해야 합니다." +} + +export const RANK_PRIZE = { + 1: '2,000,000,000원', + 2: '30,000,000원', + 3: '1,500,000원', + 4: '50,000원', + 5: '5,000원', +} + +export const RANK_MESSAGES = { + 1: `6개 일치 (${RANK_PRIZE[1]}) - `, + 2: `5개 일치, 보너스 볼 일치 (${RANK_PRIZE[2]}) - `, + 3: `5개 일치 (${RANK_PRIZE[3]}) - `, + 4: `4개 일치 (${RANK_PRIZE[4]}) - `, + 5: `3개 일치 (${RANK_PRIZE[5]}) - `, +} + diff --git a/src/utils/InputValidator.js b/src/utils/InputValidator.js new file mode 100644 index 0000000000..fc0af22c28 --- /dev/null +++ b/src/utils/InputValidator.js @@ -0,0 +1,26 @@ +import Validation from "./Validation.js"; + +class InputValidator { + static validatePurchaseAmount(amount) { + Validation.validateNumberEmpty(amount); + Validation.validateIsNumber(amount); + Validation.validateInputThousandWonUnit(amount); + } + + static validateLottoWinningNumber(array) { + Validation.validateArrayEmpty(array); + Validation.validateArrayIsNumber(array); + Validation.validateHasDuplicate(array); + Validation.validateArrayOfRange(array); + Validation.validateArrayLength(array); + } + + static validateLottoBonusNumber(array, number) { + Validation.validateNumberEmpty(number); + Validation.validateIsNumber(number); + Validation.validateNumberOfRange(number); + Validation.validateHasDuplicateArrayAndNumber(array, number); + } +} + +export default InputValidator; diff --git a/src/utils/Validation.js b/src/utils/Validation.js new file mode 100644 index 0000000000..8c6a710028 --- /dev/null +++ b/src/utils/Validation.js @@ -0,0 +1,67 @@ +import { ERROR_MESSAGES } from "./Constants.js"; + +class Validation { + static validateArrayEmpty(array) { + if (array.includes('')) { + throw new Error(ERROR_MESSAGES.EMPTY_INPUT); + } + } + + static validateNumberEmpty(input) { + if (input === '') { + throw new Error(ERROR_MESSAGES.EMPTY_INPUT); + } + } + + static validateIsNumber(input) { + if (isNaN(input)) { + throw new Error(ERROR_MESSAGES.INVALID_NUMBER_FORMAT); + } + } + + static validateInputThousandWonUnit(amount) { + if (amount % 1000 !== 0) { + throw new Error(ERROR_MESSAGES.INVAID_PURCHASE_AMOUNT); + } + } + + static validateHasDuplicate(input) { + const uniqueNumbers = new Set(input); + if (uniqueNumbers.size !== input.length) { + throw new Error(ERROR_MESSAGES.DUPLICATE_LOTTO_NUMBER); + } + } + + static validateHasDuplicateArrayAndNumber(array, input) { + if (array.includes(input)) { + throw new Error(ERROR_MESSAGES.DUPLICATE_LOTTO_NUMBER); + } + } + + static validateNumberOfRange(input) { + if (input < 1 || input > 45) { + throw new Error(ERROR_MESSAGES.BONUS_NUMBER_OUT_OF_RANGE); + } + } + + static validateArrayOfRange(array) { + console.log(array); + if (array.some(number => number < 1 || number > 45)) { + throw new Error(ERROR_MESSAGES.NUMBER_OUT_OF_RANGE); + } + } + + static validateArrayIsNumber(array) { + if (array.some(number => isNaN(number))) { + throw new Error(ERROR_MESSAGES.INVALID_NUMBER_FORMAT); + } + } + + static validateArrayLength(array) { + if (array.length !== 6) { + throw new Error(ERROR_MESSAGES.LOTTO_NUMBER_OUT_OF_RANGE); + } + } +} + +export default Validation;