diff --git a/README.md b/README.md index b168a180..4e868ae4 100644 --- a/README.md +++ b/README.md @@ -1 +1,52 @@ # javascript-planetlotto-precourse + +# 행성 로또 + +## 구현할 기능 목록 + +- 로또를 구매한다 + - 로또 1장의 가격은 500원이다. +- 로또를 발행한다 + - 로또를 구한 가격만큼 로또를 발행한다. + - 로또 한장에는 1~30 까지 수에서 중복되지 않는 수 5개가 뽑힌다. +- 당첨 번호를 입력한다. + - 1~30 사이의 중복되지 않는 수 5개 +- 보너스번호를 입력한다. + - 1~30 사이의 수 1개 + - 당첨번호와 중복되지 않는 수 +- 당첨 통계를 제공한다 + - 당첨 기준 + + ```bash + - 1등: 5개 번호 일치 / 100,000,000원 + - 2등: 4개 번호 + 보너스 번호 일치 / 10,000,000원 + - 3등: 4개 번호 일치 / 1,500,000원 + - 4등: 3개 번호 일치 + 보너스 번호 일치 / 500,000원 + - 5등: 2개 번호 일치 + 보너스 번호 일치 / 5,000원 + ``` + + - 발행한 로또와 입력한 당첨 번호의 일치 개수를 계산한다. + - 발행한 로또와 입력한 보너스 번호의 존재 여부를 계산한다. + - 당첨 기준에 따라 각 등수에 해당하는 로또의 개수를 계산한다. + +## 예외 처리 + +| 예외상황 | 에러 메시지 | +| ------------------------------------------ | ---------------------------------------------------- | +| 구입 금액이 숫자가 아닌 경우 | [ERROR] 숫자로 입력해주세요 | +| 구입 금액이 500원 단위가 아닌 경우 | [ERROR] 500원 단위로 입력해주세요. | +| 당첨 번호가 5개가 아닌 경우 | [ERROR] 로또 번호는 5개여야 합니다. | +| 당첨 번호가 1~30 사이의 숫자가 아닌 경우 | [ERROR] 로또 번호는 1부터 30 사이의 숫자여야 합니다. | +| 당첨 번호가 중복된 경우 | [ERROR] 중복 숫자가 포함되어 있습니다. | +| 보너스 번호가 1~30 사이의 숫자가 아닌 경우 | [ERROR] 로또 번호는 1부터 30 사이의 숫자여야 합니다. | +| 보너스 번호가 당첨번호와 중복된 경우 | [ERROR] 이미 입력된 숫자입니다 | + +## 도전 과제 + +### 목표 : 주어진 시간 안에 코드 품질을 높히자! + +- 모든 함수는 15줄을 넘지 않는다. +- 상수는 변수화 한다. +- 함수/변수 이름에 의미를 정확하게 포함한다. +- tdd를 적용하여 리펙토링 한다. +- mvc 구조로 분리한다. diff --git a/__tests__/BonusNumberTest.js b/__tests__/BonusNumberTest.js new file mode 100644 index 00000000..c1e955e0 --- /dev/null +++ b/__tests__/BonusNumberTest.js @@ -0,0 +1,17 @@ +import BonusNumber from '../src/model/BonusNumber.js'; +import { ERROR_MESSAGE } from '../src/constants/message.js'; + +describe('보너스 번호 클래스 테스트', () => { + const winningNums = [1, 2, 3, 4, 5, 6]; + + test('1~30 사이의 수가 아니면 예외가 발생한다.', () => { + expect(() => BonusNumber.validate(31, winningNums)).toThrow( + ERROR_MESSAGE.INCORRECT_NUMBER_OF_RANGES, + ); + }); + test('당첨번호와 중복이면 예외가 발생한다.', () => { + expect(() => BonusNumber.validate(1, winningNums)).toThrow( + ERROR_MESSAGE.DUPLICATE_NUMBER_WITH_WINNING, + ); + }); +}); diff --git a/__tests__/InputMoneyTest.js b/__tests__/InputMoneyTest.js new file mode 100644 index 00000000..4c28a411 --- /dev/null +++ b/__tests__/InputMoneyTest.js @@ -0,0 +1,17 @@ +import Money from '../src/model/Money.js'; +import { ERROR_MESSAGE } from '../src/constants/message.js'; + +describe('Money 클래스 테스트', () => { + test.each([ + ['500j', '영어가 입력된 경우'], + ['', '아무것도 입력되지 않은 경우'], + ])('숫자 외의 것이 입력되면 예외가 발생한다. (%s)', (moneys) => { + expect(() => new Money(moneys)).toThrow(ERROR_MESSAGE.NOT_NUMBER); + }); + test.each([ + [100, '100원이 입력된 경우'], + [510, '510원이 입력된 경우'], + ])('500원 단위의 입력이 아니면 예외가 발생한다(%s)', (moneys) => { + expect(() => new Money(moneys)).toThrow(ERROR_MESSAGE.NOT_UNITS_500_WON); + }); +}); diff --git a/__tests__/LottoIssueMachineTest.js b/__tests__/LottoIssueMachineTest.js new file mode 100644 index 00000000..f18d251a --- /dev/null +++ b/__tests__/LottoIssueMachineTest.js @@ -0,0 +1,15 @@ +import LottoIssueMachine from '../src/model/LottoIssueMachine.js'; + +describe('로또 발급 클래스 테스트', () => { + const money = 5000; + const lottoCount = 10; + test('입력받은 돈의 로또 발행 개수를 계산한다.', () => { + expect(LottoIssueMachine.calculatorLottoCount(money)).toBe(lottoCount); + }); + + const issuedLotto = LottoIssueMachine.issue(money); + + test('로또 발행 개수 만큼 로또를 생성한다', () => { + expect(issuedLotto.length).toBe(lottoCount); + }); +}); diff --git a/__tests__/WinningNumbersTest.js b/__tests__/WinningNumbersTest.js new file mode 100644 index 00000000..8e6e9426 --- /dev/null +++ b/__tests__/WinningNumbersTest.js @@ -0,0 +1,23 @@ +import WinningNumbers from '../src/model/WinningNumbers.js'; +import { ERROR_MESSAGE } from '../src/constants/message.js'; + +describe('당첨 번호 클래스 테스트', () => { + test.each([ + [[1, 2, 3, 4, 5, 6], '6개인 경우'], + [[1, 2, 3, 4], '4개인 경우'], + ])('로또 번호의 개수가 5개가 아니면 예외가 발생한다. (%s)', (numbers) => { + expect(() => new WinningNumbers(numbers)).toThrow(ERROR_MESSAGE.WRONG_WINNING_COUNT); + }); + + test.each([ + [[1, 2, 3, 4, 31], '31이 포함된 경우'], + [[1, 2, 3, 4, 0], '0이 포함된 경우'], + ])('로또 번호의 범위가 1~30이 아니면 예외가 발생한다.. (%s)', (numbers) => { + expect(() => new WinningNumbers(numbers)).toThrow(ERROR_MESSAGE.INCORRECT_NUMBER_OF_RANGES); + }); + test('로또 번호에 중복된 숫자가 있으면 예외가 발생한다.', () => { + expect(() => { + new WinningNumbers([1, 2, 3, 5, 5]); + }).toThrow(ERROR_MESSAGE.DUPLICATE_NUMBER); + }); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5..528d036b 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,71 @@ +import { InputView, OutputView } from './view.js'; +import LottoIssueMachine from './model/LottoIssueMachine.js'; +import WinningNumbers from './model/WinningNumbers.js'; +import BonusNumber from './model/BonusNumber.js'; +import WinningsStatisticsCalculator from './model/WinningsStatisticsCalculator.js'; +import Money from './model/Money.js'; + class App { - async run() {} + async run() { + const money = await this.getMoney(); + const issueLottos = LottoIssueMachine.issue(money); + this.printIssuedLottos(issueLottos); + + const winningNumbers = await this.getWinnerNumbers(); + const bonusNumber = await this.getBonusNumbers(winningNumbers); + + const lottoCalculation = new WinningsStatisticsCalculator( + issueLottos, + winningNumbers, + bonusNumber, + ); + this.printStatistics(lottoCalculation.getCountRank()); + + return; + } + + async getMoney() { + return this.#retry(async () => { + const inputMoney = await InputView.askAmount(); + const money = new Money(inputMoney); + return money.getMoney(); + }); + } + + async getWinnerNumbers() { + return this.#retry(async () => { + const numbers = await InputView.askWinningLotto(); + const winnerLotto = new WinningNumbers(numbers); + + return winnerLotto.getLottos(); + }); + } + + async getBonusNumbers(winning) { + return this.#retry(async () => { + const bonus = await InputView.askBonusNumber(); + BonusNumber.validate(bonus, winning); + return bonus; + }); + } + + printIssuedLottos(lottos) { + OutputView.printPurchasedLottos(lottos); + } + + printStatistics(countRank) { + OutputView.printResult(countRank); + } + + async #retry(task) { + while (true) { + try { + return await task(); + } catch (e) { + OutputView.printErrorMessage(e.message); + } + } + } } export default App; diff --git a/src/constants/data.js b/src/constants/data.js new file mode 100644 index 00000000..9662b103 --- /dev/null +++ b/src/constants/data.js @@ -0,0 +1,5 @@ +export const data = Object.freeze({ + MONEY_UNIT: 500, + MAX_NUM: 30, + MIN_NUM: 1, +}); diff --git a/src/constants/message.js b/src/constants/message.js new file mode 100644 index 00000000..2ef3eab0 --- /dev/null +++ b/src/constants/message.js @@ -0,0 +1,8 @@ +export const ERROR_MESSAGE = Object.freeze({ + NOT_NUMBER: '숫자로 입력해주세요.', + NOT_UNITS_500_WON: `500원 단위로 입력해주세요.`, + WRONG_WINNING_COUNT: '로또 번호는 5개여야 합니다.', + INCORRECT_NUMBER_OF_RANGES: '로또 번호는 1부터 30 사이의 숫자여야 합니다.', + DUPLICATE_NUMBER: '중복 숫자가 포함되어 있습니다.', + DUPLICATE_NUMBER_WITH_WINNING: '이미 입력된 숫자입니다.', +}); diff --git a/src/model/BonusNumber.js b/src/model/BonusNumber.js new file mode 100644 index 00000000..bf7aa672 --- /dev/null +++ b/src/model/BonusNumber.js @@ -0,0 +1,14 @@ +import { ERROR_MESSAGE } from '../constants/message.js'; +import { data } from '../constants/data.js'; +class BonusNumber { + static validate(bonus, winning) { + if (bonus < data.MIN_NUM || bonus > data.MAX_NUM) { + throw new Error(ERROR_MESSAGE.INCORRECT_NUMBER_OF_RANGES); + } + if (winning.includes(bonus)) { + throw new Error(ERROR_MESSAGE.DUPLICATE_NUMBER_WITH_WINNING); + } + } +} + +export default BonusNumber; diff --git a/src/model/LottoIssueMachine.js b/src/model/LottoIssueMachine.js new file mode 100644 index 00000000..aeca7431 --- /dev/null +++ b/src/model/LottoIssueMachine.js @@ -0,0 +1,24 @@ +import { MissionUtils } from '@woowacourse/mission-utils'; +import { data } from '../constants/data'; + +class LottoIssueMachine { + static issue(money) { + const count = this.calculatorLottoCount(money); + const lottos = []; + + for (let i = 1; i <= count; i++) { + lottos.push(MissionUtils.Random.pickUniqueNumbersInRange(1, 30, 5)); + } + + lottos.forEach((lotto) => { + lotto.sort((a, b) => a - b); + }); + + return lottos; + } + static calculatorLottoCount(money) { + return money / data.MONEY_UNIT; + } +} + +export default LottoIssueMachine; diff --git a/src/model/Money.js b/src/model/Money.js new file mode 100644 index 00000000..3ac681f2 --- /dev/null +++ b/src/model/Money.js @@ -0,0 +1,26 @@ +import { ERROR_MESSAGE } from '../constants/message.js'; +import { data } from '../constants/data.js'; +class Money { + #money; + + constructor(money) { + this.#validate(money); + this.#money = money; + } + + #validate(money) { + const reg = /^[1-9]\d*$/; + if (!reg.test(money)) { + throw new Error(ERROR_MESSAGE.NOT_NUMBER); + } + if (money % data.MONEY_UNIT !== 0) { + throw new Error(ERROR_MESSAGE.NOT_UNITS_500_WON); + } + } + + getMoney() { + return this.#money; + } +} + +export default Money; diff --git a/src/model/WinningNumbers.js b/src/model/WinningNumbers.js new file mode 100644 index 00000000..06ffc86d --- /dev/null +++ b/src/model/WinningNumbers.js @@ -0,0 +1,31 @@ +import { ERROR_MESSAGE } from '../constants/message.js'; +import { data } from '../constants/data.js'; +class WinningNumbers { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = numbers; + } + + #validate(numbers) { + if (numbers.length !== 5) { + throw new Error(ERROR_MESSAGE.WRONG_WINNING_COUNT); + } + + if (numbers.some((num) => num < data.MIN_NUM || num > data.MAX_NUM)) { + throw new Error(ERROR_MESSAGE.INCORRECT_NUMBER_OF_RANGES); + } + + const checkingDuplicates = new Set(numbers); + if (checkingDuplicates.size !== numbers.length) { + throw new Error(ERROR_MESSAGE.DUPLICATE_NUMBER); + } + } + + getLottos() { + return this.#numbers; + } +} + +export default WinningNumbers; diff --git a/src/model/WinningsStatisticsCalculator.js b/src/model/WinningsStatisticsCalculator.js new file mode 100644 index 00000000..9003cdab --- /dev/null +++ b/src/model/WinningsStatisticsCalculator.js @@ -0,0 +1,54 @@ +class WinningsStatisticsCalculator { + countRank = new Map([ + [1, 0], + [2, 0], + [3, 0], + [4, 0], + [5, 0], + [0, 0], + ]); + matchingMap; + lottos; + + constructor(issudeLottos, winningNumbers, bonusNumber) { + this.lottos = { issudeLottos, winningNumbers, bonusNumber }; + this.CalculateNumberOfWinning(); + this.CalculateRank(); + } + + CalculateNumberOfWinning() { + const matchingMap = this.lottos.issudeLottos.map((lotto) => { + const commonElements = lotto.filter((item) => this.lottos.winningNumbers.includes(item)); + const matchingWinning = new Set(commonElements).size; + + const matchingBonus = lotto.includes(this.lottos.bonusNumber); + + return { winning: matchingWinning, hasBonus: matchingBonus }; + }); + + this.matchingMap = matchingMap; + } + + CalculateRank() { + this.matchingMap.forEach((result) => { + if (result.winning === 5) this.setRank(1); + if (result.winning === 4 && result.hasBonus) this.setRank(2); + if (result.winning === 4) this.setRank(3); + if (result.winning === 3 && result.hasBonus) this.setRank(4); + if (result.winning === 2 && result.hasBonus) this.setRank(5); + if (result.winning === 0) this.setRank(0); + }); + } + + setRank(rank) { + let currentScore = this.countRank.get(rank); + this.countRank.set(rank, currentScore + 1); + return; + } + + getCountRank() { + return this.countRank; + } +} + +export default WinningsStatisticsCalculator;