diff --git a/README.md b/README.md index 15bb106b5..dd09ab36c 100644 --- a/README.md +++ b/README.md @@ -1 +1,121 @@ # javascript-lotto-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를 발생시키고 해당 메시지를 출력한 다음 해당 지점부터 다시 입력을 받는다. + ``` + 예시) [ERROR] 숫자가 잘못된 형식입니다. + ``` +
+- - - + +## 입출력 요구 사항 + +### 입력 + +- 로또 구입 금액을 입력 받는다. 구입 금액은 1,000원 단위로 입력 받으며 1,000원으로 나누어 떨어지지 않는 경우 예외 처리한다. + +``` +14000 +``` + +- 당첨 번호를 입력 받는다. 번호는 쉼표(,)를 기준으로 구분한다. + +``` +1,2,3,4,5,6 +``` + +- 보너스 번호를 입력 받는다. + +``` +7 +``` + +### 출력 + +- 발행한 로또 수량 및 번호를 출력한다. 로또 번호는 오름차순으로 정렬하여 보여준다. + +``` +8개를 구매했습니다. +[8, 21, 23, 41, 42, 43] +[3, 5, 11, 16, 32, 38] +[7, 11, 16, 35, 36, 44] +[1, 8, 11, 31, 41, 42] +[13, 14, 16, 38, 42, 45] +[7, 11, 30, 40, 42, 43] +[2, 13, 22, 32, 38, 45] +[1, 3, 5, 14, 22, 45] +``` + +- 당첨 내역을 출력한다. + +``` +3개 일치 (5,000원) - 1개 +4개 일치 (50,000원) - 0개 +5개 일치 (1,500,000원) - 0개 +5개 일치, 보너스 볼 일치 (30,000,000원) - 0개 +6개 일치 (2,000,000,000원) - 0개 +``` + +- 수익률은 소수점 둘째 자리에서 반올림한다. (ex. 100.0%, 51.5%, 1,000,000.0%) + +``` +총 수익률은 62.5%입니다. +``` + +- 예외 상황 시 에러 문구를 출력해야 한다. 단, 에러 문구는 "[ERROR]"로 시작해야 한다. + +``` +[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다. +``` + +### 실행 결과 예시 + +``` +구입금액을 입력해 주세요. +8000 + +8개를 구매했습니다. +[8, 21, 23, 41, 42, 43] +[3, 5, 11, 16, 32, 38] +[7, 11, 16, 35, 36, 44] +[1, 8, 11, 31, 41, 42] +[13, 14, 16, 38, 42, 45] +[7, 11, 30, 40, 42, 43] +[2, 13, 22, 32, 38, 45] +[1, 3, 5, 14, 22, 45] + +당첨 번호를 입력해 주세요. +1,2,3,4,5,6 + +보너스 번호를 입력해 주세요. +7 + +당첨 통계 +--- +3개 일치 (5,000원) - 1개 +4개 일치 (50,000원) - 0개 +5개 일치 (1,500,000원) - 0개 +5개 일치, 보너스 볼 일치 (30,000,000원) - 0개 +6개 일치 (2,000,000,000원) - 0개 +총 수익률은 62.5%입니다. +``` \ No newline at end of file diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js index 872380c9c..2c36d2e21 100644 --- a/__tests__/ApplicationTest.js +++ b/__tests__/ApplicationTest.js @@ -42,6 +42,24 @@ const runException = async (input) => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[ERROR]")); }; +const runExceptionBonus = async (bonusNumber) => { + // 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", bonusNumber, "7"]; + + mockRandoms([RANDOM_NUMBERS_TO_END]); + mockQuestions([INPUT_NUMBERS_TO_END]); + + // when + const app = new App(); + await app.run(); + + // then + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[ERROR]")); +} + describe("로또 테스트", () => { beforeEach(() => { jest.restoreAllMocks(); @@ -94,4 +112,12 @@ describe("로또 테스트", () => { test("예외 테스트", async () => { await runException("1000j"); }); + + test("보너스 번호가 당첨 번호와 중복될 경우 예외 처리", async () => { + await runExceptionBonus("3"); + }); + + test("보너스 번호가 1~45 범위를 벗어난 경우 예외 처리", async () => { + await runExceptionBonus("46"); + }); }); diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js index 409aaf69b..eb2341655 100644 --- a/__tests__/LottoTest.js +++ b/__tests__/LottoTest.js @@ -14,5 +14,27 @@ describe("로또 클래스 테스트", () => { }).toThrow("[ERROR]"); }); - // TODO: 추가 기능 구현에 따른 테스트 코드 작성 + test("로또 번호 1~45 범위를 초과하면 예외가 발생한다.", () => { + expect(() => { + new Lotto([1, 2, 3, 4, 5, 46]); + }).toThrow("[ERROR]"); + }); + + test("로또 번호가 자연수가 아니면 예외가 발생한다.", () => { + expect(() => { + new Lotto([1, 2, 3, 4, 5, 6.5]); + }).toThrow("[ERROR]"); + }); + + test("로또 번호가 음수일 경우 예외가 발생한다.", () => { + expect(() => { + new Lotto([1, -2, 3, -4, 5, -6]); + }).toThrow("[ERROR]"); + }); + + test("로또 번호가 숫자가 아니면 예외가 발생한다.", () => { + expect(() => { + new Lotto([12, "A", 1, "f", 3, "C"]); + }).toThrow("[ERROR]"); + }); }); diff --git a/src/App.js b/src/App.js index 091aa0a5d..b04f75f57 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,15 @@ +import Controller from "./controller/Controller.js"; + class App { - async run() {} + #controller; + + constructor() { + this.#controller = new Controller(); + } + + async run() { + await this.#controller.run(); + } } export default App; diff --git a/src/constants/constants.js b/src/constants/constants.js new file mode 100644 index 000000000..0f35c4f60 --- /dev/null +++ b/src/constants/constants.js @@ -0,0 +1,67 @@ +export const INPUT_MESSAGE = { + inputPurchaseAmount : "구입 금액을 입력해 주세요.\n", + inputWinningNumbers : "\n당첨 번호를 입력해 주세요.\n", + inputBonusNumber : "\n보너스 번호를 입력해 주세요.\n", +}; + +export const OUTPUT_MESSAGE = { + printPurchaseNumber : (count) => `\n${count}개를 구매했습니다.`, + printWinningStatistics : "\n당첨 통계\n---", + printFifth : (count) => `3개 일치 (5,000원) - ${count}개`, + printFourth : (count) => `4개 일치 (50,000원) - ${count}개`, + printThird : (count) => `5개 일치 (1,500,000원) - ${count}개`, + printSecond : (count) => `5개 일치, 보너스 볼 일치 (30,000,000원) - ${count}개`, + printFirst : (count) => `6개 일치 (2,000,000,000원) - ${count}개`, + printRateReturn : (rate) => `총 수익률은 ${rate}%입니다.`, +}; + +const ERROR_PREFIX = "[ERROR]"; + +export const ERROR_MESSAGE = { + nullData : `${ERROR_PREFIX} 값을 입력해야 합니다.`, + purchaseError : `${ERROR_PREFIX} 구입 금액은 1000으로 나누어 떨어지는 수 입니다.`, + purchaseRangeError : `${ERROR_PREFIX} 구입 금액 범위는 양수인 정수입니다.`, + + lottoLengthError : `${ERROR_PREFIX} 당첨 번호는 6개여야 합니다.`, + lottoDuplicatedError : `${ERROR_PREFIX} 당첨 번호는 중복될 수 없습니다.`, + lottoRangeError : `${ERROR_PREFIX} 당첨 번호의 범위는 1~45 폐구간 입니다.`, + lottoTypeError : `${ERROR_PREFIX} 당첨 번호는 자연수입니다.`, + + bonusLengthError : `${ERROR_PREFIX} 보너스 번호는 1개여야 합니다.`, + bonusDuplicatedError : `${ERROR_PREFIX} 보너스 번호는 당첨 번호와 중복될 수 없습니다.`, + bonusRangeError : `${ERROR_PREFIX} 보너스 번호의 범위는 1~45 폐구간 입니다.`, + bonusTypeError : `${ERROR_PREFIX} 보너스 번호는 자연수입니다.`, +}; + +export const LOTTO_RELATED_CONSTANTS = { + lottoPrice : 1000, + lottoRangeStart : 1, + lottoRangeEnd : 45, + lottoLength : 6, + rate : 100, + rounding : 1, +}; + +export const MATCH_REWARD = { + fifth : 5000, + fourth : 50000, + third : 1500000, + second : 30000000, + first : 2000000000, +}; + +export const MATCH = { + init : 0, + three : 3, + four : 4, + five : 5, + six : 6, +}; + +export const MATCH_RANK = { + fifit : 0, + fourth : 1, + third : 2, + second : 3, + first : 4, +}; \ No newline at end of file diff --git a/src/controller/Controller.js b/src/controller/Controller.js new file mode 100644 index 000000000..538a20d77 --- /dev/null +++ b/src/controller/Controller.js @@ -0,0 +1,34 @@ +import InputView from "../view/InputView.js"; +import OutputView from "../view/OutputView.js"; +import LottoService from "../domain/LottoService.js"; + +class Controller { + #inputView; + #outputView; + #lottoService; + + constructor() { + this.#inputView = new InputView(); + this.#outputView = new OutputView(); + this.#lottoService = new LottoService(); + } + + async run() { + const payment = await this.#inputView.readPurchaseAmount(); // 지불 가격 유효성 검사 필요 + const numberOfPurchase = this.#lottoService.purchaseLottos(payment); + + this.#lottoService.generateLottos(numberOfPurchase); + const myLottos = this.#lottoService.getLottos(); + this.#outputView.printMyLotto(numberOfPurchase, myLottos); + + const winningNumber = await this.#inputView.readWinningLotto(); // 당첨 번호 유효성 검사 필요 + const bonusNumber = await this.#inputView.readBonusNumber(); // 보너스 번호 유효성 검사 필요 + + const match = this.#lottoService.compareWithWinningNumbers(winningNumber, Number(bonusNumber)); + const rewardRate = this.#lottoService.calculateRate(match, payment); + + this.#outputView.printResult(match, rewardRate); + } +} + +export default Controller; \ No newline at end of file diff --git a/src/Lotto.js b/src/domain/Lotto.js similarity index 85% rename from src/Lotto.js rename to src/domain/Lotto.js index cb0b1527e..87d6db3f2 100644 --- a/src/Lotto.js +++ b/src/domain/Lotto.js @@ -12,7 +12,9 @@ class Lotto { } } - // TODO: 추가 기능 구현 + getNumbers() { + return this.#numbers; + } } export default Lotto; diff --git a/src/domain/LottoRepository.js b/src/domain/LottoRepository.js new file mode 100644 index 000000000..18c58c2c9 --- /dev/null +++ b/src/domain/LottoRepository.js @@ -0,0 +1,36 @@ +import { MATCH_REWARD } from "../constants/constants.js"; +import { MATCH } from "../constants/constants.js"; + +class LottoRepository { + lottos; + match; + matchReward; + + constructor() { + this.lottos = []; + this.match = [MATCH.init, MATCH.init, MATCH.init, MATCH.init, MATCH.init]; + this.matchReward = [MATCH_REWARD.fifth, MATCH_REWARD.fourth, MATCH_REWARD.third, MATCH_REWARD.second, MATCH_REWARD.first]; + } + + saveLotto(lotto) { + this.lottos.push(lotto); + } + + getLottos() { + return this.lottos; + } + + getMatch() { + return this.match; + } + + updateMatch(number) { + this.match[number]++; + } + + getMatchReward() { + return this.matchReward; + } +} + +export default LottoRepository; \ No newline at end of file diff --git a/src/domain/LottoService.js b/src/domain/LottoService.js new file mode 100644 index 000000000..016d7d5ee --- /dev/null +++ b/src/domain/LottoService.js @@ -0,0 +1,78 @@ +import Lotto from "./Lotto.js"; +import LottoRepository from "./LottoRepository.js"; +import { LOTTO_RELATED_CONSTANTS } from "../constants/constants.js"; +import { MATCH } from "../constants/constants.js"; +import { MATCH_RANK } from "../constants/constants.js"; +import { MissionUtils } from "@woowacourse/mission-utils"; + +class LottoService { + #lottoRepository; + #random + + constructor(){ + this.#lottoRepository = new LottoRepository(); + this.#random = MissionUtils.Random.pickUniqueNumbersInRange; + } + + #generateRandomLotto() { + const lottoNumbers = this.#random(LOTTO_RELATED_CONSTANTS.lottoRangeStart, LOTTO_RELATED_CONSTANTS.lottoRangeEnd, LOTTO_RELATED_CONSTANTS.lottoLength); + lottoNumbers.sort((a, b) => a - b); + + const lotto = new Lotto(lottoNumbers); + this.#lottoRepository.saveLotto(lotto); + } + + generateLottos(number) { + for (let idx = 0; idx < number; idx++) { + this.#generateRandomLotto(); + } + } + + purchaseLottos(payment) { + const numberOfPurchase = payment / LOTTO_RELATED_CONSTANTS.lottoPrice; + return numberOfPurchase; + } + + getLottos() { + return this.#lottoRepository.getLottos(); + } + + #updateMatch(match, isHaveBonus) { + switch(match){ + case MATCH.three : this.#lottoRepository.updateMatch(MATCH_RANK.fifit); break; + case MATCH.four : this.#lottoRepository.updateMatch(MATCH_RANK.fourth); break; + case MATCH.six : this.#lottoRepository.updateMatch(MATCH_RANK.first); break; + } + if (match == MATCH.five && !isHaveBonus){ + this.#lottoRepository.updateMatch(MATCH_RANK.third); + } + if (match == MATCH.five && isHaveBonus){ + this.#lottoRepository.updateMatch(MATCH_RANK.second); + } + } + + compareWithWinningNumbers(winningNumbers, bonusNumber) { + const myLottos = this.getLottos(); + + for (let lotto of myLottos) { + let match = lotto.getNumbers().filter(element => winningNumbers.includes(element)).length; + let isHaveBonus = lotto.getNumbers().includes(bonusNumber); + + this.#updateMatch(match, isHaveBonus); + } + + return this.#lottoRepository.getMatch(); + } + + calculateRate(match, payment) { + let matchReward = this.#lottoRepository.getMatchReward(); + let reward = match.reduce((sum, value, index) => { + return sum + (value * matchReward[index]); + }, 0); + + let rate = (reward / payment * LOTTO_RELATED_CONSTANTS.rate).toFixed(LOTTO_RELATED_CONSTANTS.rounding); + return rate; + } +} + +export default LottoService; \ No newline at end of file diff --git a/src/validation/Validator.js b/src/validation/Validator.js new file mode 100644 index 000000000..e7be693bd --- /dev/null +++ b/src/validation/Validator.js @@ -0,0 +1,79 @@ +import { ERROR_MESSAGE } from "../constants/constants.js"; + +class Validator { + //입력 에러 + static inputNullData(data) { + if (data === null || data.trim() === "") { + throw new Error (ERROR_MESSAGE.nullData); + } + } + + static inputPurchaseValidation(price) { + if (!Number.isInteger(price / 1000)) { + throw new Error(ERROR_MESSAGE.purchaseError); + } + } + + static purchaseRangeValidation(price) { + if (price <= 0) { + throw new Error(ERROR_MESSAGE.purchaseRangeError); + } + } + + // 당첨 번호 에러 + static lottoLengthValidation(numbers) { + if (numbers.length !== 6) { + throw new Error(ERROR_MESSAGE.lottoLengthError); + } + } + + static lottoDuplicatedValidation(numbers) { + const isDuplicated = numbers.some(function(x) { + return numbers.indexOf(x) !== numbers.lastIndexOf(x); + }); + if (isDuplicated) { + throw new Error(ERROR_MESSAGE.lottoDuplicatedError); + } + } + + static lottoRangeValidation(numbers) { + const isOutOfRange = numbers.some(function(number) { + return number < 1 || number > 45; + }); + if (isOutOfRange) { + throw new Error(ERROR_MESSAGE.lottoRangeError); + } + } + + static lottoTypeValidation(numbers) { + const isTypeValid = numbers.every((element) => { + return Number.isInteger(element); + }); + if (!isTypeValid) { + throw new Error(ERROR_MESSAGE.lottoTypeError); + } + } + + //보너스 번호 에러 + static bonusLengthValidation(number) { + if (number.length !== 1) { + throw new Error(ERROR_MESSAGE.bonusLengthError); + } + } + + static bonusRangeValidation(number) { + const isOutOfRange = number < 1 || number > 45; + if (isOutOfRange) { + throw new Error(ERROR_MESSAGE.bonusRangeError); + } + } + + static bonusTypeValidation(number) { + const isTypeValid = Number.isInteger(number); + if (!isTypeValid) { + throw new Error(ERROR_MESSAGE.bonusTypeError); + } + } +} + +export default Validator; \ No newline at end of file diff --git a/src/view/InputView.js b/src/view/InputView.js new file mode 100644 index 000000000..9df1d7315 --- /dev/null +++ b/src/view/InputView.js @@ -0,0 +1,57 @@ +import { Console } from "@woowacourse/mission-utils"; +import { INPUT_MESSAGE } from "../constants/constants.js"; +import Validator from "../validation/Validator.js"; + +class InputView { + #read; + + constructor() { + this.#read = Console.readLineAsync; + } + + #validate(data) { + Validator.inputNullData(data); + } + + #separateNumber(data) { + return data.split(",").map(Number); + } + + async readPurchaseAmount() { + try { + const inputPrice = await this.#read(INPUT_MESSAGE.inputPurchaseAmount); + this.#validate(inputPrice); + return inputPrice; + } catch(e) { + console.error(e.message); + return await this.readPurchaseAmount(); + } + + } + + async readWinningLotto() { + try { + const winningdata = await this.#read(INPUT_MESSAGE.inputWinningNumbers); + const winningNumber = this.#separateNumber(winningdata); + this.#validate(winningNumber); + return winningNumber; + } catch(e) { + console.error(e.message); + return await this.readWinningLotto(); + } + } + + async readBonusNumber() { + try { + const bonusData = await this.#read(INPUT_MESSAGE.inputBonusNumber); + const bonusNumber = this.#separateNumber(bonusData); + this.#validate(bonusNumber); + return bonusNumber; + } catch(e) { + console.error(e.message); + return await this.readBonusNumber(); + } + } +} + +export default InputView; \ No newline at end of file diff --git a/src/view/OutputView.js b/src/view/OutputView.js new file mode 100644 index 000000000..534e2fa43 --- /dev/null +++ b/src/view/OutputView.js @@ -0,0 +1,30 @@ +import { Console } from "@woowacourse/mission-utils"; +import { OUTPUT_MESSAGE } from "../constants/constants.js"; + +class OutputView { + #print; + + constructor() { + this.#print = Console.print; + } + + printMyLotto(purchaseNumber, myLottos) { + this.#print(OUTPUT_MESSAGE.printPurchaseNumber(purchaseNumber)); + + for (let element of myLottos) { + this.#print(element.getNumbers()); + } + } + + printResult(match, rate) { + this.#print(OUTPUT_MESSAGE.printWinningStatistics); + this.#print(OUTPUT_MESSAGE.printFifth(match[0])); + this.#print(OUTPUT_MESSAGE.printFourth(match[1])); + this.#print(OUTPUT_MESSAGE.printThird(match[2])); + this.#print(OUTPUT_MESSAGE.printSecond(match[3])); + this.#print(OUTPUT_MESSAGE.printFirst(match[4])); + this.#print(OUTPUT_MESSAGE.printRateReturn(rate)); + } +} + +export default OutputView; \ No newline at end of file