diff --git a/README.md b/README.md index b168a180..4918d111 100644 --- a/README.md +++ b/README.md @@ -1 +1,30 @@ # javascript-planetlotto-precourse + +## 구현할 기능 목록 + +- [x] 사용자 입력 처리하기 + - [x] 로또 구입 금액 입력 + - [x] 당첨 번호 입력 + - [x] 보너스 번호 입력 + - [x] 잘못된 값이 입력된 경우 해당 지점부터 다시 입력 받기 +- [x] 잘못된 값에 대한 예외 처리 + - [x] 로또 구입 금액으로 숫자가 아닌 값이 들어오는 경우 + - [x] 당첨 번호로 숫자가 아닌 값이 들어오는 경우 + - [x] 당첨 번호로 양수가 아닌 값이 들어오는 경우 + - [x] 당첨 번호로 정수가 아닌 값이 들어오는 경우 + - [x] 당첨 번호로 1부터 30이 아닌 값이 들어오는 경우 + - [x] 당첨 번호가 5개가 아닌 경우 + - [x] 당첨 번호가 서로 중복되는 경우 + - [x] 보너스 번호로 숫자가 아닌 값이 들어오는 경우 + - [x] 보너스 번호로 양수가 아닌 값이 들어오는 경우 + - [x] 보너스 번호로 정수가 아닌 값이 들어오는 경우 + - [x] 보너스 번호로 1부터 30이 아닌 값이 들어오는 경우 + - [x] 보너스 번호가 당첨 번호와 중복되는 경우 +- [x] 로또 구입 금액만큼 로또 발행 +- [x] 구입한 로또 출력 + - [x] 로또 번호는 오름차순 정렬 +- [x] 당첨 통계 출력 +- [ ] 코드 리팩토링 + - [x] 매직 넘버 제거 + - [x] mvc 패턴 도입 +- [x] 테스트 코드 작성 diff --git a/__tests__/LottoStoreTest.js b/__tests__/LottoStoreTest.js new file mode 100644 index 00000000..83889a02 --- /dev/null +++ b/__tests__/LottoStoreTest.js @@ -0,0 +1,17 @@ +import LottoStore from '../src/model/lottoStore.js'; + +describe('LottoStore 클래스 테스트', () => { + describe('publishTicketsByAmount() 메서드 테스트', () => { + test('구입 금액이 1000원인 경우 2장을 발급', () => { + const lottoStore = new LottoStore(); + const lottoTickets = lottoStore.publishTicketsByAmount(1000); + expect(lottoTickets).toHaveLength(2); + }); + + test('구입 금액이 0인 경우 로또를 발행하지 않음', () => { + const lottoStore = new LottoStore(); + const lottoTickets = lottoStore.publishTicketsByAmount(0); + expect(lottoTickets).toHaveLength(0); + }); + }); +}); diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js new file mode 100644 index 00000000..990e4da1 --- /dev/null +++ b/__tests__/LottoTest.js @@ -0,0 +1,8 @@ +import Lotto from '../src/model/lotto.js'; + +describe('Lotto 클래스 테스트', () => { + test('로또 번호가 오름차순으로 정렬되어 저장됨', () => { + const ticket = new Lotto([5, 4, 3, 2, 1]); + expect(ticket.getNumbers()).toEqual([1, 2, 3, 4, 5]); + }); +}); diff --git a/__tests__/ValidatorTest.js b/__tests__/ValidatorTest.js new file mode 100644 index 00000000..fa671bd2 --- /dev/null +++ b/__tests__/ValidatorTest.js @@ -0,0 +1,34 @@ +import { LOTTO } from '../src/constants.js'; +import Validator from '../src/utility/validator.js'; + +describe('Validator 테스트', () => { + test('배열안에 중복되는 값이 있는 경우 예외 처리', () => { + expect(() => { + Validator.validateUnique([1, 1, 2], ''); + }).toThrow(''); + }); + + test(`배열의 길이가 ${LOTTO.COUNT}개를 충족하지 않는 경우 예외 처리`, () => { + expect(() => { + Validator.validateCount([1, 2, 3, 4], ''); + }).toThrow(''); + }); + + test('입력값이 정수가 아닌 경우 예외 처리', () => { + expect(() => { + Validator.validateInteger(3.14, ''); + }).toThrow(''); + }); + + test('입력값이 양수가 아닌 경우 예외 처리', () => { + expect(() => { + Validator.validatePositive(-1, ''); + }).toThrow(''); + }); + + test(`입력값이 ${LOTTO.MIN_NUMBER}에서 ${LOTTO.MAX_NUMBER}까지의 값이 아닌 경우 예외 처리`, () => { + expect(() => { + Validator.validateRange(31, ''); + }).toThrow(''); + }); +}); diff --git a/__tests__/WinningLottoTest.js b/__tests__/WinningLottoTest.js new file mode 100644 index 00000000..395720bc --- /dev/null +++ b/__tests__/WinningLottoTest.js @@ -0,0 +1,42 @@ +import Lotto from '../src/model/lotto.js'; +import WinningLotto from '../src/model/winningLotto.js'; + +describe('WinningLotto 클래스 테스트', () => { + describe('getNumbers() 메서드 테스트', () => { + test('당첨 번호를 반환', () => { + const winningLotto = new WinningLotto([1, 2, 3, 4, 5], 6); + expect(winningLotto.getNumbers()).toEqual([1, 2, 3, 4, 5]); + }); + }); + + describe('getBonusNumber() 메서드 테스트', () => { + test('보너스 번호를 반환', () => { + const winningLotto = new WinningLotto([1, 2, 3, 4, 5], 6); + expect(winningLotto.getBonusNumber()).toBe(6); + }); + }); + + describe('evaluateTickets() 메서드 테스트', () => { + test('로또 평가 결과를 반환', () => { + const winningLotto = new WinningLotto([1, 2, 3, 4, 5], 6); + const tickets = [ + new Lotto([30, 29, 28, 27, 26]), + new Lotto([1, 2, 3, 4, 5]), + new Lotto([1, 2, 3, 4, 6]), + new Lotto([1, 2, 3, 4, 7]), + new Lotto([1, 2, 3, 6, 7]), + new Lotto([1, 2, 6, 7, 8]), + ]; + expect(winningLotto.evaluateTickets(tickets)).toEqual( + new Map([ + [0, 1], + [1, 1], + [2, 1], + [3, 1], + [4, 1], + [5, 1], + ]), + ); + }); + }); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5..45395b2d 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,10 @@ +import LottoConroller from './controller/lottoController.js'; + class App { - async run() {} + async run() { + const lottoController = new LottoConroller(); + await lottoController.start(); + } } export default App; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 00000000..783b26a1 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,15 @@ +export const LOTTO = { + PRICE: 500, + COUNT: 5, + MIN_NUMBER: 1, + MAX_NUMBER: 30, +}; + +export const RANK = { + FIRST: 1, + SECOND: 2, + THIRD: 3, + FOURTH: 4, + FIFTH: 5, + NONE: 0, +}; diff --git a/src/controller/lottoController.js b/src/controller/lottoController.js new file mode 100644 index 00000000..0533cd90 --- /dev/null +++ b/src/controller/lottoController.js @@ -0,0 +1,36 @@ +import { InputView, OutputView } from './../view.js'; +import WinningLotto from './../model/winningLotto.js'; +import LottoStore from './../model/lottoStore.js'; + +class LottoConroller { + #lottoStore; + + constructor() { + this.#lottoStore = new LottoStore(); + } + + async start() { + const tickets = await this.purchaseLottosAsync(); + OutputView.printPurchasedLottos(tickets.map((ticket) => ticket.getNumbers())); + + const { winningNumbers, bonusNumber } = await this.getLastWinningLottoAsync(); + const winningLotto = new WinningLotto(winningNumbers, bonusNumber); + + const counts = winningLotto.evaluateTickets(tickets); + OutputView.printResult(counts); + } + + async purchaseLottosAsync() { + const amount = await InputView.getAmountAsync(); + const tickets = this.#lottoStore.publishTicketsByAmount(amount); + return tickets; + } + + async getLastWinningLottoAsync() { + const winningNumbers = await InputView.getWinningLottoAsync(); + const bonusNumber = await InputView.getBonusNumberAsync(winningNumbers); + return { winningNumbers, bonusNumber }; + } +} + +export default LottoConroller; diff --git a/src/model/lotto.js b/src/model/lotto.js new file mode 100644 index 00000000..eba1d673 --- /dev/null +++ b/src/model/lotto.js @@ -0,0 +1,13 @@ +class Lotto { + #numbers; + + constructor(numbers) { + this.#numbers = [...numbers].sort((a, b) => a - b); + } + + getNumbers() { + return [...this.#numbers]; + } +} + +export default Lotto; diff --git a/src/model/lottoStore.js b/src/model/lottoStore.js new file mode 100644 index 00000000..ece39134 --- /dev/null +++ b/src/model/lottoStore.js @@ -0,0 +1,26 @@ +import { Random } from '@woowacourse/mission-utils'; +import { LOTTO } from './../constants.js'; +import Lotto from './lotto.js'; + +class LottoStore { + #randomLottoNumbersGenerator; + + constructor(randomLottoNumbersGenerator) { + this.#randomLottoNumbersGenerator = + randomLottoNumbersGenerator ?? + (() => Random.pickUniqueNumbersInRange(LOTTO.MIN_NUMBER, LOTTO.MAX_NUMBER, LOTTO.COUNT)); + } + + publishTicketsByAmount(amount) { + const tickets = []; + const count = Math.floor(amount / LOTTO.PRICE); + + for (let i = 1; i <= count; i++) { + tickets.push(new Lotto([...this.#randomLottoNumbersGenerator()])); + } + + return tickets; + } +} + +export default LottoStore; diff --git a/src/model/winningLotto.js b/src/model/winningLotto.js new file mode 100644 index 00000000..cbed4291 --- /dev/null +++ b/src/model/winningLotto.js @@ -0,0 +1,43 @@ +import { RANK } from '../constants.js'; +import Lotto from './lotto.js'; + +class WinningLotto extends Lotto { + #bonusNumber; + + constructor(numbers, bonusNumber) { + super(numbers); + this.#bonusNumber = bonusNumber; + } + + getBonusNumber() { + return this.#bonusNumber; + } + + #evaluateTicket(numbers) { + const matchCount = numbers.filter((number) => this.getNumbers().includes(number)).length; + const hasBonus = numbers.includes(this.#bonusNumber); + + if (matchCount === 5) return RANK.FIRST; + if (matchCount === 4 && hasBonus) return RANK.SECOND; + if (matchCount === 4) return RANK.THIRD; + if (matchCount === 3 && hasBonus) return RANK.FOURTH; + if (matchCount === 2 && hasBonus) return RANK.FIFTH; + return RANK.NONE; + } + + evaluateTickets(tickets) { + const counts = [RANK.NONE, RANK.FIRST, RANK.SECOND, RANK.THIRD, RANK.FOURTH, RANK.FIFTH].reduce((acc, rank) => { + acc.set(rank, 0); + return acc; + }, new Map()); + + tickets.forEach((ticket) => { + const grade = this.#evaluateTicket(ticket.getNumbers()); + counts.set(grade, counts.get(grade) + 1); + }); + + return counts; + } +} + +export default WinningLotto; diff --git a/src/utility/validator.js b/src/utility/validator.js new file mode 100644 index 00000000..ec06eddc --- /dev/null +++ b/src/utility/validator.js @@ -0,0 +1,56 @@ +import { LOTTO } from '../constants.js'; + +const Validator = { + validateUnique(numbers, message) { + if (new Set([...numbers]).size !== numbers.length) { + throw new Error(message); + } + }, + + validateCount(numbers, message) { + if (numbers.length !== LOTTO.COUNT) { + throw new Error(message); + } + }, + + validatePositive(number, message) { + if (number < 1) { + throw new Error(message); + } + }, + + validateInteger(number, message) { + if (number % 1 !== 0) { + throw new Error(message); + } + }, + + validateRange(number, message) { + if (number < LOTTO.MIN_NUMBER || number > LOTTO.MAX_NUMBER) { + throw new Error(message); + } + }, + + validateLottoNumbers(lottoNumbers) { + this.validateCount(lottoNumbers, '당첨 번호는 5개입니다.'); + this.validateUnique(lottoNumbers, '당첨 번호는 서로 중복될 수 없습니다.'); + + lottoNumbers.forEach((number) => { + this.validatePositive(number, '당첨 번호는 양수이어야 합니다.'); + this.validateInteger(number, '당첨 번호는 정수이어야 합니다.'); + this.validateRange(number, `당첨 번호는 ${LOTTO.MIN_NUMBER}에서 ${LOTTO.MAX_NUMBER}까지의 정수이어야합니다.`); + }); + }, + + validateBonusNumber(bonusNumber, lottoNumbers) { + this.validatePositive(bonusNumber, '보너스 번호는 양수이어야 합니다.'); + this.validateInteger(bonusNumber, '보너스 번호는 정수이어야 합니다.'); + this.validateRange( + bonusNumber, + `보너스 번호는 ${LOTTO.MIN_NUMBER}에서 ${LOTTO.MAX_NUMBER}까지의 정수이어야합니다.`, + ); + this.validateUnique([bonusNumber, ...lottoNumbers], '보너스 번호는 당첨 번호와 중복될 수 없습니다.'); + }, +}; + +export default Validator; diff --git a/src/view.js b/src/view.js index ae6afd9c..8dc00011 100644 --- a/src/view.js +++ b/src/view.js @@ -1,4 +1,5 @@ -import { MissionUtils } from "@woowacourse/mission-utils"; +import { MissionUtils } from '@woowacourse/mission-utils'; +import Validator from './utility/validator.js'; const InputView = { /** @@ -42,6 +43,41 @@ const InputView = { } return num; }, + + async getAmountAsync() { + while (true) { + try { + const amount = await this.askAmount(); + return amount; + } catch (error) { + OutputView.printErrorMessage(error.message); + } + } + }, + + async getWinningLottoAsync() { + while (true) { + try { + const winningLotto = await this.askWinningLotto(); + Validator.validateLottoNumbers(winningLotto); + return winningLotto; + } catch (error) { + OutputView.printErrorMessage(error.message); + } + } + }, + + async getBonusNumberAsync(winningLotto) { + while (true) { + try { + const bonusNumber = await this.askBonusNumber(); + Validator.validateBonusNumber(bonusNumber, winningLotto); + return bonusNumber; + } catch (error) { + OutputView.printErrorMessage(error.message); + } + } + }, }; const OutputView = { @@ -49,10 +85,7 @@ const OutputView = { * @param {number[][]} lottos */ printPurchasedLottos(lottos) { - const lines = [ - `${lottos.length}개를 구매했습니다.`, - ...lottos.map(lotto => `[${lotto.join(', ')}]`), - ]; + const lines = [`${lottos.length}개를 구매했습니다.`, ...lottos.map((lotto) => `[${lotto.join(', ')}]`)]; MissionUtils.Console.print(lines.join('\n')); },