diff --git a/README.md b/README.md index b168a180..344492a7 100644 --- a/README.md +++ b/README.md @@ -1 +1,58 @@ # javascript-planetlotto-precourse + +### 기능 목록 + +1. 구매 금액 입력 +2. 구매 개수 및 로또 번호 출력 + 1. 로또 1개 가격: 500원 + 2. 로또 번호는 1-30 사이 중복되지 않는 5개의 숫자 +3. 당첨 번호 입력 + 1. 당첨 번호는 1-30 사이 중복되지 않는 5개의 숫자 + 2. 쉼표로 구분 +4. 보너스 번호 입력 + 1. 당첨 번호와 중복되지 않는 1-30 사이 숫자 +5. 당첨 번호 비교 + - 1등: 5개 번호 일치 / 100,000,000원 + - 2등: 4개 번호 + 보너스 번호 일치 / 10,000,000원 + - 3등: 4개 번호 일치 / 1,500,000원 + - 4등: 3개 번호 일치 + 보너스 번호 일치 / 500,000원 + - 5등: 2개 번호 일치 + 보너스 번호 일치 / 5,000원 +6. 당첨 통계 출력 + +### 테스트 사항 + +1. 정상 기능 동작 +2. 구매 금액에 문자 입력 시 예외 + +### 실행 결과 예시 + +```md +구입금액을 입력해 주세요. +1000 + +2개를 구매했습니다. +[8, 11, 13, 21, 22] +[1, 3, 6, 14, 22] + +당첨 번호를 입력해 주세요. +1, 2, 3, 4, 5 + +보너스 번호 번호를 입력해 주세요. +6 + +당첨 통계 +--- +5개 일치 (100,000,000원) - 0개 +4개 일치, 보너스 번호 일치 (10,000,000원) - 0개 +4개 일치 (1,500,000원) - 0개 +3개 일치, 보너스 번호 일치 (500,000원) - 0개 +2개 일치, 보너스 번호 일치 (5,000원) - 1개 +0개 일치 (0원) - 1개 +``` + +### 도전 과제 + +리팩터링 + +- 연습했던 대로 말고, 익숙하지 않은 방식으로 +- getter를 줄이고, 되도록 tell하도록 diff --git a/__tests__/LottoNumberTest.js b/__tests__/LottoNumberTest.js new file mode 100644 index 00000000..8585c704 --- /dev/null +++ b/__tests__/LottoNumberTest.js @@ -0,0 +1,28 @@ +import LottoNumber from '../src/lotto/LottoNumber.js'; +import { LOTTO_NUMBER } from '../src/constants/lotto.js'; + +describe('LottoNumber 클래스', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + test('equals: 두 LottoNumber를 비교하여 동일한 number를 가지면 true를 반환하다.', () => { + const lottoNumber1 = new LottoNumber(1); + const lottoNumber2 = new LottoNumber(1); + expect(lottoNumber1.equals(lottoNumber2)).toBe(true); + }); + + test('equals: 두 LottoNumber를 비교하여 서로 다른 number를 가지면 false를 반환하다.', () => { + const lottoNumber1 = new LottoNumber(1); + const lottoNumber2 = new LottoNumber(2); + expect(lottoNumber1.equals(lottoNumber2)).toBe(false); + }); + + test(`exception: 로또 번호가 ${LOTTO_NUMBER.MIN}보다 작으면 예외 발생한다.`, () => { + expect(() => new LottoNumber(LOTTO_NUMBER.MIN - 1)).toThrow(); + }); + + test(`exception: 로또 번호가 ${LOTTO_NUMBER.MAX}보다 크면 예외 발생한다.`, () => { + expect(() => new LottoNumber(LOTTO_NUMBER.MAX + 1)).toThrow(); + }); +}); diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js new file mode 100644 index 00000000..7f53dac2 --- /dev/null +++ b/__tests__/LottoTest.js @@ -0,0 +1,38 @@ +import Lotto from '../src/lotto/Lotto.js'; +import LottoNumber from '../src/lotto/LottoNumber.js'; + +describe('Lotto 클래스', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + test('includes: Lotto에 포함된 LottoNumber가 들어오면 true를 반환하다.', () => { + const lotto = new Lotto([1, 2, 3, 4, 5]); + const lottoNumber = new LottoNumber(1); + expect(lotto.includes(lottoNumber)).toBe(true); + }); + + test('includes: Lotto에 포함되지 않은 LottoNumber가 들어오면 false를 반환하다.', () => { + const lotto = new Lotto([1, 2, 3, 4, 5]); + const lottoNumber = new LottoNumber(6); + expect(lotto.includes(lottoNumber)).toBe(false); + }); + + test('matchCount: 두 Lotto를 비교하여 겹치는 숫자의 개수를 반환한다.', () => { + const lotto1 = new Lotto([1, 2, 3, 4, 5]); + const lotto2 = new Lotto([1, 2, 3, 4, 6]); + expect(lotto1.matchCount(lotto2)).toBe(4); + }); + + test('exception: 중복 번호가 포함되어 있으면 예외를 발생한다.', () => { + expect(() => new Lotto([1, 2, 3, 4, 4])).toThrow(); + }); + + test('exception: 로또 번호 개수가 5보다 작으면 예외를 발생한다.', () => { + expect(() => new Lotto([1, 2, 3, 4])).toThrow(); + }); + + test('exception: 로또 번호 개수가 5보다 크면 예외를 발생한다.', () => { + expect(() => new Lotto([1, 2, 3, 4, 5, 6])).toThrow(); + }); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5..6740d53a 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,73 @@ +import { InputView, OutputView } from './view.js'; +import Lotto from './lotto/Lotto.js'; +import LottoNumber from './lotto/LottoNumber.js'; +import LottoResult from './lotto/LottoResult.js'; +import WinningLottoAndBonusNumberBuilder from './lotto/WinningLottoAndBonusNumberBuilder.js'; +import LottoStore from './lotto/LottoStore.js'; + class App { - async run() {} + constructor() { + this.inputView = InputView; + this.outputView = OutputView; + this.lottoStore = new LottoStore(); + } + + async run() { + const lottos = await this.createLotto(); + this.outputView.printPurchasedLottos(lottos); + + const winningLottoAndBonusNumber = await this.createWinningLottoAndBonusNumber(); + const lottoResult = new LottoResult(lottos, winningLottoAndBonusNumber); + this.outputView.printResult(lottoResult); + } + + async createLotto() { + return await this.#retryOnError(async () => { + const amount = await this.readAmount(); + return this.lottoStore.purchase(amount); + }); + } + + async readAmount() { + return await this.#retryOnError(async () => { + return await this.inputView.askAmount(); + }); + } + + async createWinningLottoAndBonusNumber() { + const builder = new WinningLottoAndBonusNumberBuilder(); + + await this.readWinningLotto(builder); + await this.readBonusNumber(builder); + + return builder.build(); + } + + async readWinningLotto(builder) { + return await this.#retryOnError(async () => { + const winningLottoInput = await this.inputView.askWinningLotto(); + const winningLotto = new Lotto(winningLottoInput); + builder.winningLotto(winningLotto); + }); + } + + async readBonusNumber(builder) { + return await this.#retryOnError(async () => { + const bonusNumberInput = await this.inputView.askBonusNumber(); + const bonusNumber = new LottoNumber(bonusNumberInput); + builder.bonusNumber(bonusNumber); + }); + } + + async #retryOnError(callback) { + while (true) { + try { + return await callback(); + } catch (error) { + this.outputView.printErrorMessage(error.message); + } + } + } } export default App; diff --git a/src/constants/lotto.js b/src/constants/lotto.js new file mode 100644 index 00000000..b2db7bb0 --- /dev/null +++ b/src/constants/lotto.js @@ -0,0 +1,17 @@ +export const RANK = { + FIRST: 1, + SECOND: 2, + THIRD: 3, + FOURTH: 4, + FIFTH: 5, + ZERO: 0, +}; + +export const LOTTO_PRICE = 500; + +export const LOTTO_NUMBER = { + MIN: 1, + MAX: 31, +}; + +export const LOTTO_LENGTH = 5; diff --git a/src/lotto/Lotto.js b/src/lotto/Lotto.js new file mode 100644 index 00000000..d4b94bfd --- /dev/null +++ b/src/lotto/Lotto.js @@ -0,0 +1,42 @@ +import LottoNumber from './LottoNumber.js'; +import { LOTTO_LENGTH } from '../constants/lotto.js'; + +class Lotto { + #lottoNumbers; + + constructor(numbers) { + this.#validateNumbers(numbers); + this.#lottoNumbers = numbers.sort((a, b) => a - b).map((num) => new LottoNumber(num)); + } + + join(delimiter) { + const numbers = this.#lottoNumbers.map((lottoNumber) => lottoNumber.getNumber()); + return numbers.join(delimiter); + } + + includes(otherLottoNumber) { + return this.#lottoNumbers.some((lottoNumber) => lottoNumber.equals(otherLottoNumber)); + } + + matchCount(lotto) { + return this.#lottoNumbers.reduce((total, lottoNumber) => { + if (lotto.includes(lottoNumber)) { + return total + 1; + } + return total; + }, 0); + } + + #validateNumbers(numbers) { + const numberSet = new Set(numbers); + if (numbers.length !== numberSet.size) { + throw new Error('중복 번호가 포함되어 있습니다.'); + } + + if (numbers.length !== LOTTO_LENGTH) { + throw new Error('로또 번호는 5개여야 합니다.'); + } + } +} + +export default Lotto; diff --git a/src/lotto/LottoNumber.js b/src/lotto/LottoNumber.js new file mode 100644 index 00000000..3e779689 --- /dev/null +++ b/src/lotto/LottoNumber.js @@ -0,0 +1,26 @@ +import { LOTTO_NUMBER } from '../constants/lotto.js'; + +class LottoNumber { + #number; + + constructor(number) { + this.#validate(number); + this.#number = number; + } + + #validate(number) { + if (number < LOTTO_NUMBER.MIN || number > LOTTO_NUMBER.MAX) { + throw new Error(`로또 번호는 ${LOTTO_NUMBER.MIN}부터 ${LOTTO_NUMBER.MAX} 사이의 숫자여야 합니다.`); + } + } + + equals(lottoNumber) { + return this.#number === lottoNumber.getNumber(); + } + + getNumber() { + return this.#number; + } +} + +export default LottoNumber; diff --git a/src/lotto/LottoResult.js b/src/lotto/LottoResult.js new file mode 100644 index 00000000..c832c202 --- /dev/null +++ b/src/lotto/LottoResult.js @@ -0,0 +1,40 @@ +import { RANK } from '../constants/lotto.js'; + +class LottoResult { + #ranks; + + constructor(lottos, winningLottoAndBonusNumber) { + this.#ranks = this.#calculateRanks(lottos, winningLottoAndBonusNumber); + } + + #calculateRanks(lottos, winningLottoAndBonusNumber) { + const ranks = new Map(Object.values(RANK).map((rank) => [rank, 0])); + + lottos.forEach((lotto) => { + const matchCount = winningLottoAndBonusNumber.matchCount(lotto); + const hasBonusNumber = winningLottoAndBonusNumber.hasBonusNumberIn(lotto); + + const rank = this.#getRank(matchCount, hasBonusNumber); + if (Number.isInteger(rank)) { + ranks.set(rank, ranks.get(rank) + 1); + } + }); + + return ranks; + } + + #getRank(matchCount, hasBonusNumber) { + if (matchCount === 5) return RANK.FIRST; + if (matchCount === 4 && hasBonusNumber) return RANK.SECOND; + if (matchCount === 4) return RANK.THIRD; + if (matchCount === 3 && hasBonusNumber) return RANK.FOURTH; + if (matchCount === 2 && hasBonusNumber) return RANK.FIFTH; + if (matchCount === 0 && !hasBonusNumber) return RANK.ZERO; + } + + get(rank) { + return this.#ranks.get(rank); + } +} + +export default LottoResult; diff --git a/src/lotto/LottoStore.js b/src/lotto/LottoStore.js new file mode 100644 index 00000000..32abf80c --- /dev/null +++ b/src/lotto/LottoStore.js @@ -0,0 +1,27 @@ +import { Random } from '@woowacourse/mission-utils'; +import { LOTTO_LENGTH, LOTTO_NUMBER, LOTTO_PRICE } from '../constants/lotto.js'; +import Lotto from './Lotto.js'; + +class LottoStore { + purchase(amount) { + this.#validateAmount(amount); + const count = Math.floor(amount / LOTTO_PRICE); + + return Array.from({ length: count }, () => { + const randomNumbers = this.#getRandomNumbers(); + return new Lotto(randomNumbers); + }); + } + + #validateAmount(amount) { + if (amount < LOTTO_PRICE) { + throw new Error(`로또는 1장에 ${LOTTO_PRICE}원입니다. ${LOTTO_PRICE}원 이상 입력해 주세요.`); + } + } + + #getRandomNumbers() { + return Random.pickUniqueNumbersInRange(LOTTO_NUMBER.MIN, LOTTO_NUMBER.MAX, LOTTO_LENGTH); + } +} + +export default LottoStore; diff --git a/src/lotto/WinningLottoAndBonusNumber.js b/src/lotto/WinningLottoAndBonusNumber.js new file mode 100644 index 00000000..af06b027 --- /dev/null +++ b/src/lotto/WinningLottoAndBonusNumber.js @@ -0,0 +1,20 @@ +class WinningLottoAndBonusNumber { + #winningLotto; + #bonusNumber; + + // builder를 통한 생성으로 제한 + constructor(builder) { + this.#winningLotto = builder.getWinningLotto(); + this.#bonusNumber = builder.getBonusNumber(); + } + + matchCount(lotto) { + return this.#winningLotto.matchCount(lotto); + } + + hasBonusNumberIn(lotto) { + return lotto.includes(this.#bonusNumber); + } +} + +export default WinningLottoAndBonusNumber; diff --git a/src/lotto/WinningLottoAndBonusNumberBuilder.js b/src/lotto/WinningLottoAndBonusNumberBuilder.js new file mode 100644 index 00000000..9cd0d793 --- /dev/null +++ b/src/lotto/WinningLottoAndBonusNumberBuilder.js @@ -0,0 +1,37 @@ +import WinningLottoAndBonusNumber from './WinningLottoAndBonusNumber.js'; + +class WinningLottoAndBonusNumberBuilder { + #winningLotto; + #bonusNumber; + + winningLotto(winningLotto) { + this.#winningLotto = winningLotto; + return this; + } + + bonusNumber(bonusNumber) { + this.#validateBonusNumber(bonusNumber); + this.#bonusNumber = bonusNumber; + return this; + } + + build() { + return new WinningLottoAndBonusNumber(this); + } + + getWinningLotto() { + return this.#winningLotto; + } + + getBonusNumber() { + return this.#bonusNumber; + } + + #validateBonusNumber(bonusNumber) { + if (this.#winningLotto && this.#winningLotto.includes(bonusNumber)) { + throw new Error('이미 당첨 번호에 포함된 번호입니다.'); + } + } +} + +export default WinningLottoAndBonusNumberBuilder; diff --git a/src/view.js b/src/view.js index ae6afd9c..556b6d45 100644 --- a/src/view.js +++ b/src/view.js @@ -1,4 +1,4 @@ -import { MissionUtils } from "@woowacourse/mission-utils"; +import { MissionUtils } from '@woowacourse/mission-utils'; const InputView = { /** @@ -20,15 +20,15 @@ const InputView = { 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; - }); + .replaceAll(' ', '') + .split(',') + .map((s) => { + const n = parseInt(s, 10); + if (Number.isNaN(n)) { + throw new Error('당첨 번호는 숫자여야 합니다.'); + } + return n; + }); }, /** @@ -51,7 +51,7 @@ const OutputView = { printPurchasedLottos(lottos) { const lines = [ `${lottos.length}개를 구매했습니다.`, - ...lottos.map(lotto => `[${lotto.join(', ')}]`), + ...lottos.map(lotto => `[${lotto.join(', ')}]`), ]; MissionUtils.Console.print(lines.join('\n')); },