diff --git a/README.md b/README.md index b168a180..7917c1ec 100644 --- a/README.md +++ b/README.md @@ -1 +1,53 @@ # javascript-planetlotto-precourse + +## 최종 테스트 - <🌏 행성 로또 🎱> 기능 구현 목록 + +### 입력 처리 + +- [x] 사용자로부터 로또 구입 금액 입력받기 (`askAmount()` 사용) + - [x] return -> number +- [x] 사용자로부터 당첨 번호 입력받기 (`askWinningLotto()` 사용) + - [x] return -> number[] + - [x] Lotto class 만들어서 사용 (멤버 => numbers) +- [x] 사용자로부터 보너스 번호 입력받기 (`askBonusNumber()` 사용) + - [x] return -> number + +### 결과 계산 + +- [x] 입력 받은 로또 구입 금액 / 500 계산하여 로또 수량 도출 + - [x] 각 발행한 로또에 중복되지 않은 5개의 정수(1부터 30 사이) 부여 (`MissionUtils.Random.pickUniqueNumbersInRange(1, 30, 5)` 사용) + - [x] Lotto class instance 생성 + - [x] 로또 번호 오름차순 정렬 +- [x] 입력 받은 당첨 번호와 발행된 로또의 5개 정수를 비교 + - [x] 5개 모두 일치하지 않고 4개만 일치했을 경우 발행된 로또의 4개 정수와 보너스 번호를 비교 + - [x] 4개 모두 일치하지 않고 3개만 일치했을 경우 발행된 로또의 3개 정수와 보너스 번호를 비교 + - [x] 3개 모두 일치하지 않고 3개만 일치했을 경우 발행된 로또의 2개 정수와 보너스 번호를 비교 +- [x] 당첨 내역 계산 + - [x] 1등부터 6등 각각의 개수 + +### 예외 처리 + +- [x] Error 발생 시 `process.exit()`을 호출하지 않고 `[ERROR]` 메시지와 함께 Error throw + - [x] 'OutputView의 `printErrorMessage(message)` 사용' +- [x] 구입 금액 입력 + - [x] 빈 문자열 입력 시 + - [x] 숫자가 아닌 값 입력 시 + - [x] 500 미만 값 입력 시 + - [x] 500으로 나눴을 때 정수형 숫자로 나눠떨어지지 않는 경우 +- [x] 당첨 번호 입력 + - [x] 1부터 30 사이 외의 로또 번호 입력 시 + - [x] 정수가 아닌 값 입력 시 + - [x] 5개 숫자가 아닌 갯수 입력 시 (빈 문자열도 같이 검증 역할) + - [x] 중복된 번호 포함 시 +- [x] 보너스 번호 입력 + - [x] 1부터 30 사이 외의 로또 번호 입력 시 + - [x] 빈 문자열 입력 시 + - [x] 정수가 아닌 값 입력 시 + - [x] 당첨 번호와 중복 시 + +### 출력 + +- [x] 발행한 로또 수량 출력 (`printPurchasedLottos(lottos)` 사용) + - [x] lottos -> `number[][]` e.g. numbers[[1,2,3,4,5],[6,7,8,9,10]] +- [x] 당첨 내역 출력 (`printResult(countsByRank)` 사용) + - [x] countsByRank -> `Map` e.g. [ 1, firstCount ], [ 2, secondCount ] diff --git a/__tests__/LottoFactoryTest.js b/__tests__/LottoFactoryTest.js new file mode 100644 index 00000000..33be3fae --- /dev/null +++ b/__tests__/LottoFactoryTest.js @@ -0,0 +1,15 @@ +import LottoFactory from '../src/model/LottoFactory.js'; + +describe('로또 팩토리 테스트', () => { + test('로또를 구매한 개수만큼만 생성한다.', () => { + const lottos = LottoFactory.createLotto(5); + + expect(lottos).toHaveLength(5); + }); + + test('정상적으로 에러없이 로또가 생성된다.', () => { + expect(() => { + LottoFactory.createLotto(5); + }).not.toThrow(); + }); +}); diff --git a/__tests__/LottoStatisticsTest.js b/__tests__/LottoStatisticsTest.js new file mode 100644 index 00000000..08296ee3 --- /dev/null +++ b/__tests__/LottoStatisticsTest.js @@ -0,0 +1,118 @@ +import LottoStatistics from '../src/model/LottoStatistics.js'; +import Lotto from '../src/model/Lotto.js'; + +describe('LottoStatistics 테스트(calculateRankResults Map 테스트)', () => { + test('당첨된 로또가 없으면 꽝이 2개이다. (2개 구매시)', () => { + const lottos = [new Lotto([1, 2, 3, 4, 5]), new Lotto([7, 8, 9, 10, 11])]; + const winningLotto = new Lotto([13, 14, 15, 16, 17]); + const bonusNumber = 19; + + const result = LottoStatistics.calculateRankResults(lottos, winningLotto, bonusNumber); + + const expectedMap = new Map([ + [1, 0], + [2, 0], + [3, 0], + [4, 0], + [5, 0], + [0, 2], + ]); + + expect(result).toEqual(expectedMap); + }); + + test('1등이 1개 있으면 1등 개수가 1이고 꽝 개수가 1개이다. (2개 구매시)', () => { + const lottos = [new Lotto([1, 2, 3, 4, 5]), new Lotto([7, 8, 9, 10, 11])]; + const winningLotto = new Lotto([1, 2, 3, 4, 5]); + const bonusNumber = 7; + + const result = LottoStatistics.calculateRankResults(lottos, winningLotto, bonusNumber); + + const expectedMap = new Map([ + [1, 1], + [2, 0], + [3, 0], + [4, 0], + [5, 0], + [0, 1], + ]); + + expect(result).toEqual(expectedMap); + }); + + test('2등이 1개 있으면 2등 개수가 1이고 꽝 개수가 1개이다. (2개 구매시)', () => { + const lottos = [new Lotto([1, 2, 3, 4, 5]), new Lotto([8, 9, 10, 11, 12])]; + const winningLotto = new Lotto([1, 2, 3, 4, 6]); + const bonusNumber = 5; + + const result = LottoStatistics.calculateRankResults(lottos, winningLotto, bonusNumber); + + const expectedMap = new Map([ + [1, 0], + [2, 1], + [3, 0], + [4, 0], + [5, 0], + [0, 1], + ]); + + expect(result).toEqual(expectedMap); + }); + + test('3등이 1개 있으면 3등 개수가 1이고 꽝 개수가 1개이다. (2개 구매시)', () => { + const lottos = [new Lotto([1, 2, 3, 4, 5]), new Lotto([9, 10, 11, 12, 13])]; + const winningLotto = new Lotto([1, 2, 3, 4, 8]); + const bonusNumber = 7; + + const result = LottoStatistics.calculateRankResults(lottos, winningLotto, bonusNumber); + + const expectedMap = new Map([ + [1, 0], + [2, 0], + [3, 1], + [4, 0], + [5, 0], + [0, 1], + ]); + + expect(result).toEqual(expectedMap); + }); + + test('4등이 1개 있으면 4등 개수가 1이고 꽝 개수가 1개이다. (2개 구매시)', () => { + const lottos = [new Lotto([1, 2, 3, 4, 8]), new Lotto([10, 11, 12, 13, 14])]; + const winningLotto = new Lotto([1, 2, 3, 6, 7]); + const bonusNumber = 4; + + const result = LottoStatistics.calculateRankResults(lottos, winningLotto, bonusNumber); + + const expectedMap = new Map([ + [1, 0], + [2, 0], + [3, 0], + [4, 1], + [5, 0], + [0, 1], + ]); + + expect(result).toEqual(expectedMap); + }); + + test('5등이 1개 있고 4등이 1개 있으면 5등, 4등 개수가 각각 1이다. (2개 구매시)', () => { + const lottos = [new Lotto([1, 2, 3, 8, 9]), new Lotto([1, 2, 8, 20, 25])]; + const winningLotto = new Lotto([1, 2, 3, 4, 5]); + const bonusNumber = 8; + + const result = LottoStatistics.calculateRankResults(lottos, winningLotto, bonusNumber); + + const expectedMap = new Map([ + [1, 0], + [2, 0], + [3, 0], + [4, 1], + [5, 1], + [0, 0], + ]); + + expect(result).toEqual(expectedMap); + }); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5..24cc8f10 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,10 @@ +import LottoController from './controller/LottoController.js'; + class App { - async run() {} + async run() { + const lottoController = new LottoController(); + await lottoController.run(); + } } export default App; diff --git a/src/constants/lotto.js b/src/constants/lotto.js new file mode 100644 index 00000000..8566b4fc --- /dev/null +++ b/src/constants/lotto.js @@ -0,0 +1,6 @@ +export const LOTTO = { + PRICE: 500, + NUMBER_COUNT: 5, + MIN_NUMBER: 1, + MAX_NUMBER: 30, +}; diff --git a/src/constants/messages.js b/src/constants/messages.js new file mode 100644 index 00000000..846f3c07 --- /dev/null +++ b/src/constants/messages.js @@ -0,0 +1,9 @@ +export const ERROR_MESSAGES = { + INVALID_LOTTO_COUNT: '로또 번호는 5개여야 합니다.', + DUPLICATE_LOTTO_NUMBERS: '로또 번호에 중복된 숫자가 있습니다.', + LOTTO_NUMBER_OUT_OF_RANGE: '로또 번호에 1부터 30 사이가 아닌 값이 있습니다.', + BELOW_MINIMUM_PURCHASE: '500 미만 값이 입력되었습니다.', + INVALID_PURCHASE_UNIT: '입력값이 500으로 나눴을 때 정수형 숫자로 나눠떨어지지 않습니다.', + BONUS_NUMBER_OUT_OF_RANGE: '보너스 번호에 1부터 30 사이가 아닌 값이 있습니다.', + BONUS_NUMBER_DUPLICATE: '보너스 번호가 당첨 번호와 중복됩니다.', +}; diff --git a/src/constants/ranks.js b/src/constants/ranks.js new file mode 100644 index 00000000..bb02f32c --- /dev/null +++ b/src/constants/ranks.js @@ -0,0 +1,7 @@ +export const RANKS = [ + { rank: 1, matchingCount: 5, hasBonusNumber: false, winningAmount: 100000000 }, + { rank: 2, matchingCount: 4, hasBonusNumber: true, winningAmount: 10000000 }, + { rank: 3, matchingCount: 4, hasBonusNumber: false, winningAmount: 1500000 }, + { rank: 4, matchingCount: 3, hasBonusNumber: true, winningAmount: 500000 }, + { rank: 5, matchingCount: 2, hasBonusNumber: true, winningAmount: 5000 }, +]; diff --git a/src/controller/LottoController.js b/src/controller/LottoController.js new file mode 100644 index 00000000..c0109ac2 --- /dev/null +++ b/src/controller/LottoController.js @@ -0,0 +1,82 @@ +import { InputView, OutputView } from '../view/view.js'; +import { validatePurchaseAmount, validateBonusNumber } from '../utils/validator.js'; +import { getLottoQuantity } from '../service/LottoQuantityCalculator.js'; +import LottoFactory from '../model/LottoFactory.js'; +import LottoStatistics from '../model/LottoStatistics.js'; +import Lotto from '../model/Lotto.js'; +import { newLinePrinter } from '../utils/newline_printer.js'; + +class LottoController { + async run() { + // 로또 금액 입력 받기 + let inputErrorFlag = true; + let lottoPurchaseAmount = 0; + while (inputErrorFlag) { + try { + lottoPurchaseAmount = await InputView.askAmount(); + validatePurchaseAmount(lottoPurchaseAmount); + inputErrorFlag = false; + } catch (error) { + OutputView.printErrorMessage(error.message); + } + } + + // 로또 갯수 도출하기 + const lottoQuantity = getLottoQuantity(lottoPurchaseAmount); + + // 로또 배열 생성하기(팩토리 패턴) + const lottos = LottoFactory.createLotto(lottoQuantity); + + // 구매한 로또 출력 + newLinePrinter(); + OutputView.printPurchasedLottos(lottos); + newLinePrinter(); + + // 당첨 번호 입력 + inputErrorFlag = true; + const winningLotto = []; + while (inputErrorFlag) { + try { + const winningNumbers = await InputView.askWinningLotto(); + + winningLotto.push(new Lotto(winningNumbers.sort((a, b) => a - b))); + inputErrorFlag = false; + } catch (error) { + OutputView.printErrorMessage(error.message); + } + } + + // 보너스 번호 입력 + inputErrorFlag = true; + let bonusNumber = 0; + + while (inputErrorFlag) { + try { + bonusNumber = await InputView.askBonusNumber(); + validateBonusNumber(bonusNumber, winningLotto[0]); + + inputErrorFlag = false; + } catch (error) { + OutputView.printErrorMessage(error.message); + } + } + + // 구매한 로또 인스턴스 생성 후 로또 랭크 갯수 도출 + const lottoInstances = []; + lottos.forEach((lotto) => { + lottoInstances.push(new Lotto(lotto)); + }); + + const countsByRank = LottoStatistics.calculateRankResults( + lottoInstances, + winningLotto[0], + bonusNumber, + ); + + // 로또 당첨 통계 출력 + newLinePrinter(); + OutputView.printResult(countsByRank); + } +} + +export default LottoController; diff --git a/src/model/Lotto.js b/src/model/Lotto.js new file mode 100644 index 00000000..a2d1d070 --- /dev/null +++ b/src/model/Lotto.js @@ -0,0 +1,45 @@ +import { LOTTO } from '../constants/lotto.js'; +import { ERROR_MESSAGES } from '../constants/messages.js'; + +class Lotto { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = numbers; + } + + #validate(numbers) { + if (numbers.length !== LOTTO.NUMBER_COUNT) { + throw new Error(ERROR_MESSAGES.INVALID_LOTTO_COUNT); + } + + const uniqueArr = new Set(numbers); + if (numbers.length !== uniqueArr.size) { + throw new Error(ERROR_MESSAGES.DUPLICATE_LOTTO_NUMBERS); + } + + if (!numbers.every((number) => number <= LOTTO.MAX_NUMBER && number >= LOTTO.MIN_NUMBER)) { + throw new Error(ERROR_MESSAGES.LOTTO_NUMBER_OUT_OF_RANGE); + } + } + + getMatchingCount(winningLotto) { + const winningNumbers = winningLotto.getNumbers(); + const matchingCount = winningNumbers.filter((winningNumber) => + this.#numbers.includes(winningNumber), + ); + + return matchingCount.length; + } + + getNumbers() { + return this.#numbers; + } + + hasBonusNumber(bonusNumber) { + return this.#numbers.includes(bonusNumber); + } +} + +export default Lotto; diff --git a/src/model/LottoFactory.js b/src/model/LottoFactory.js new file mode 100644 index 00000000..7a0a62f7 --- /dev/null +++ b/src/model/LottoFactory.js @@ -0,0 +1,14 @@ +import { Random } from '@woowacourse/mission-utils'; + +class LottoFactory { + static createLotto(lottoQuantity) { + const lottoArray = Array.from({ length: lottoQuantity }, () => { + const numbers = Random.pickUniqueNumbersInRange(1, 30, 5); + return numbers.sort((a, b) => a - b); + }); + + return lottoArray; + } +} + +export default LottoFactory; diff --git a/src/model/LottoStatistics.js b/src/model/LottoStatistics.js new file mode 100644 index 00000000..58762f64 --- /dev/null +++ b/src/model/LottoStatistics.js @@ -0,0 +1,38 @@ +import RankFinder from './RankFinder.js'; + +class LottoStatistics { + static calculateRankResults(lottos, winningLotto, bonusNumber) { + const lottoRankResults = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 0: 0 }; + + lottos.forEach((lotto) => { + this.updateRankResult(lotto, winningLotto, bonusNumber, lottoRankResults); + }); + + const countsByRank = new Map(); + + for (let i = 0; i < Object.keys(lottoRankResults).length; i++) { + countsByRank.set(i, lottoRankResults[i]); + } + + return countsByRank; + } + + static updateRankResult(lotto, winningLotto, bonusNumber, lottoRankResults) { + const matchingCount = lotto.getMatchingCount(winningLotto); + const hasBonusNumber = lotto.hasBonusNumber(bonusNumber); + const rank = RankFinder.getRank(matchingCount, hasBonusNumber); + + if (!rank) { + this.addRankCount(lottoRankResults, 0); + return; + } + + this.addRankCount(lottoRankResults, rank.rank); + } + + static addRankCount(lottoRankResults, rank) { + lottoRankResults[rank] += 1; + } +} + +export default LottoStatistics; diff --git a/src/model/RankFinder.js b/src/model/RankFinder.js new file mode 100644 index 00000000..15a986c6 --- /dev/null +++ b/src/model/RankFinder.js @@ -0,0 +1,35 @@ +import { RANKS } from '../constants/ranks.js'; + +class RankFinder { + static getRank(matchingCount, hasBonusNumber) { + if (matchingCount === 4) { + const secondRank = RANKS.find( + (rank) => rank.matchingCount === matchingCount && rank.hasBonusNumber === hasBonusNumber, + ); + + return secondRank; + } + + if (matchingCount === 3) { + const fourthRank = RANKS.find( + (rank) => rank.matchingCount === matchingCount && rank.hasBonusNumber === hasBonusNumber, + ); + + return fourthRank; + } + + if (matchingCount === 2) { + const fifthRank = RANKS.find( + (rank) => rank.matchingCount === matchingCount && rank.hasBonusNumber === hasBonusNumber, + ); + + return fifthRank; + } + + const rank = RANKS.find((rank) => rank.matchingCount === matchingCount); + + return rank; + } +} + +export default RankFinder; diff --git a/src/service/LottoQuantityCalculator.js b/src/service/LottoQuantityCalculator.js new file mode 100644 index 00000000..c1af09c6 --- /dev/null +++ b/src/service/LottoQuantityCalculator.js @@ -0,0 +1,3 @@ +export function getLottoQuantity(lottoPurchaseAmount) { + return lottoPurchaseAmount / 500; +} diff --git a/src/utils/newline_printer.js b/src/utils/newline_printer.js new file mode 100644 index 00000000..907417ad --- /dev/null +++ b/src/utils/newline_printer.js @@ -0,0 +1,5 @@ +import { Console } from '@woowacourse/mission-utils'; + +export function newLinePrinter() { + Console.print(''); +} diff --git a/src/utils/validator.js b/src/utils/validator.js new file mode 100644 index 00000000..c784153c --- /dev/null +++ b/src/utils/validator.js @@ -0,0 +1,22 @@ +import { LOTTO } from '../constants/lotto.js'; +import { ERROR_MESSAGES } from '../constants/messages.js'; + +export function validatePurchaseAmount(lottoPurchaseAmount) { + if (lottoPurchaseAmount < LOTTO.PRICE) { + throw new Error(ERROR_MESSAGES.BELOW_MINIMUM_PURCHASE); + } + + if (!Number.isInteger(lottoPurchaseAmount / LOTTO.PRICE)) { + throw new Error(ERROR_MESSAGES.INVALID_PURCHASE_UNIT); + } +} + +export function validateBonusNumber(bonusNumber, winningLotto) { + if (!(bonusNumber <= LOTTO.MAX_NUMBER && bonusNumber >= LOTTO.MIN_NUMBER)) { + throw new Error(ERROR_MESSAGES.BONUS_NUMBER_OUT_OF_RANGE); + } + + if (winningLotto.hasBonusNumber(bonusNumber)) { + throw new Error(ERROR_MESSAGES.BONUS_NUMBER_DUPLICATE); + } +} diff --git a/src/view.js b/src/view/view.js similarity index 95% rename from src/view.js rename to src/view/view.js index ae6afd9c..5989899a 100644 --- a/src/view.js +++ b/src/view/view.js @@ -1,4 +1,4 @@ -import { MissionUtils } from "@woowacourse/mission-utils"; +import { MissionUtils } from '@woowacourse/mission-utils'; const InputView = { /** @@ -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')); },