diff --git a/README.md b/README.md index 15bb106b5..2ae0bb34f 100644 --- a/README.md +++ b/README.md @@ -1 +1,67 @@ -# javascript-lotto-precourse +## 기능 요구사항 분석 + +### 입력/출력 + +**입력** + +- [x] 로또 구입 금액을 입력받는다. +- [x] 당첨 번호와 보너스 번호를 입력받는다. +- [x] 잘못된 값을 입력했을 경우 메시지와 함께 에러를 발생시킨 후 +해당 지점부터 다시 입력 받는다. + +**출력** + +- [x] 발행할 로또 개수를 바탕으로 로또 수량 및 번호 리스트들을 출력한다. +- [x] 당첨 내역을 출력한다. +- [x] 수익률을 출력한다. +- [x] 당첨 내역 및 수익률을 출력하고 로또 게임을 종료한다. + +
+ +### 기능 + +**로또 발행** + +- [x] 로또 구입 금액만큼 발행할 로또 개수를 정한다. +- [x] 로또 개수만큼 로또를 발행한다. +- [x] 발행한 로또 번호를 오름차순으로 정렬한다. + +**결과 계산** + +- [x] 발행된 로또와 당첨 번호를 비교하여 당첨 내역을 계산한다. +- [x] 당첨 내역을 바탕으로 총 수익률을 계산한다. + + 수익률은 소수점 둘째 자리에서 반올림한다. + + +
+ +### 에러 + +**입력값** + +- [x] 공백인 경우 +- 로또 구입 금액 + - [x] 구입 금액의 단위가 1,000으로 나누어 떨어지지 않을 경우 + - [x] 숫자가 아닌 문자가 포함된 경우 +- 당첨 번호 + - [x] 구분자가 쉼표(,)가 아닌 경우 + - [x] 숫자가 아닌 문자가 포함된 경우 + - [x] 구분자 형식이 잘못된 경우 (e.g. ‘1,2,3,’ ‘1,2,,3’) + - [x] 구분자 사이에 공백이 포함된 경우 (e.g. ‘1, 2,3’ ‘1,2 ,3’) + - [x] 입력된 숫자가 6개가 아닌 경우 + - [x] 중복된 번호가 입력된 경우 + - [x] 1~45 범위의 숫자가 아닌 경우 +- 보너스 번호 + - [x] 숫자가 아닌 문자가 포함된 경우 + +**로또 발행** + +- [x] 중복된 번호가 발행된 경우 +- [x] 발행된 번호가 6개가 아닌 경우 + +
+ +### 엣지 케이스 + +- [x] 당첨되지 않았을 때 (2개 이하로 번호 일치) \ No newline at end of file diff --git a/__tests__/InputValidatorTest.js b/__tests__/InputValidatorTest.js new file mode 100644 index 000000000..6bda359cb --- /dev/null +++ b/__tests__/InputValidatorTest.js @@ -0,0 +1,77 @@ +import { ERROR_MESSAGE, TERMS } from '../src/utils/constants'; +import InputValidator from '../src/utils/InputValidator'; + +describe('입력값 검증 테스트 (로또 구입 금액)', () => { + it.each([ + ['공백이 입력된 경우', ' ', ERROR_MESSAGE.BLANK_INPUT], + [ + '숫자가 아닌 문자가 포함된 경우', + 'lotto', + ERROR_MESSAGE.PURCHASE_AMOUNT_TYPE, + ], + [ + '1,000원으로 나누어 떨어지지 않는 경우', + '900', + ERROR_MESSAGE.PURCHASE_AMOUNT_UNIT, + ], + ])('%s', (_, input, expectedError) => { + expect(() => + InputValidator.runValidate(TERMS.PURCHASE_AMOUNT, input) + ).toThrow(expectedError); + }); +}); + +describe('입력값 검증 테스트 (당첨 번호)', () => { + it.each([ + ['공백이 입력된 경우', ' ', ERROR_MESSAGE.BLANK_INPUT], + [ + '구분자가 쉼표(,)가 아닌 경우', + '1,2;3,4,5,6', + ERROR_MESSAGE.WINNING_NUMBER_TYPE, + ], + [ + '구분자 형식이 잘못된 경우', + ',1,2,3,4,5,6', + ERROR_MESSAGE.WINNING_NUMBER_TYPE, + ], + [ + '숫자가 아닌 문자가 포함된 경우', + '1,2,h,4,5,6', + ERROR_MESSAGE.WINNING_NUMBER_TYPE, + ], + [ + '숫자가 6개가 아닌 경우', + '1,2,3,4,5', + ERROR_MESSAGE.WINNING_NUMBER_LENGTH, + ], + [ + '중복된 숫자가 포함된 경우', + '1,2,3,4,5,5', + ERROR_MESSAGE.WINNING_NUMBER_DUPLICATE, + ], + [ + '1~45 범위 밖의 숫자가 포함된 경우', + '1,2,3,4,5,50', + ERROR_MESSAGE.WINNING_NUMBER_RANGE, + ], + ])('%s', (_, input, expectedError) => { + expect(() => + InputValidator.runValidate(TERMS.WINNING_NUMBER, input) + ).toThrow(expectedError); + }); +}); + +describe('입력값 검증 테스트 (보너스 번호)', () => { + it.each([ + ['공백이 입력된 경우', ' ', ERROR_MESSAGE.BLANK_INPUT], + [ + '숫자가 아닌 문자가 포함된 경우', + 'lotto', + ERROR_MESSAGE.BONUS_NUMBER_TYPE, + ], + ])('%s', (_, input, expectedError) => { + expect(() => InputValidator.runValidate(TERMS.BONUS_NUMBER, input)).toThrow( + expectedError + ); + }); +}); diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js index 409aaf69b..9abfb89f7 100644 --- a/__tests__/LottoTest.js +++ b/__tests__/LottoTest.js @@ -1,18 +1,15 @@ -import Lotto from "../src/Lotto"; +import Lotto from '../src/model/Lotto'; -describe("로또 클래스 테스트", () => { - test("로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.", () => { +describe('로또 클래스 테스트', () => { + test('로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.', () => { expect(() => { new Lotto([1, 2, 3, 4, 5, 6, 7]); - }).toThrow("[ERROR]"); + }).toThrow('[ERROR]'); }); - // TODO: 테스트가 통과하도록 프로덕션 코드 구현 - test("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.", () => { + test('로또 번호에 중복된 숫자가 있으면 예외가 발생한다.', () => { expect(() => { new Lotto([1, 2, 3, 4, 5, 5]); - }).toThrow("[ERROR]"); + }).toThrow('[ERROR]'); }); - - // TODO: 추가 기능 구현에 따른 테스트 코드 작성 }); diff --git a/src/App.js b/src/App.js index 091aa0a5d..03f4976be 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,80 @@ +import { Console } from '@woowacourse/mission-utils'; +import LottoMachine from './model/LottoMachine.js'; +import { IO_MESSAGE, SEPERATOR, TERMS } from './utils/constants.js'; +import Input from './view/Input.js'; +import Parser from './utils/Parser.js'; +import RankCalculator from './model/RankCalculator.js'; +import StatisticsView from './view/StatisticsView.js'; + class App { - async run() {} + #issuedLottos = []; + + /** + * 1. 구입 금액을 입력받고 숫자로 변환 + */ + async getPurchaseAmount() { + const purchaseAmountStr = await Input.readInputValues( + TERMS.PURCHASE_AMOUNT, + IO_MESSAGE.PURCHASE_AMOUNT_INPUT + ); + return Number(purchaseAmountStr); + } + + /** + * 2. 로또 발행 + */ + issueLottos(purchaseAmount) { + this.#issuedLottos = LottoMachine.run(purchaseAmount); + } + + /** + * 3. 발행된 로또 번호를 정렬하여 출력 + */ + printIssuedLottos() { + this.#issuedLottos.forEach((lotto) => { + const numbers = lotto.getNumbers(); + numbers.sort((a, b) => a - b); + Console.print(`[${numbers.join(', ')}]`); + }); + } + + /** + * 4. 당첨 번호와 보너스 번호를 입력받고 파싱 + */ + async getWinningNumbers() { + const winningNumber = await Input.readInputValues( + TERMS.WINNING_NUMBER, + IO_MESSAGE.WINNING_NUMBER_INPUT + ); + const parsedWinningNumber = Parser.convertToNumberArray( + winningNumber, + SEPERATOR.COMMA + ); + + const bonusNumber = await Input.readInputValues( + TERMS.BONUS_NUMBER, + IO_MESSAGE.BONUS_NUMBER_INPUT + ); + + return { winningNumber: parsedWinningNumber, bonusNumber }; + } + + // 전체 프로세스 실행 + async run() { + const purchaseAmount = await this.getPurchaseAmount(); + this.issueLottos(purchaseAmount); + this.printIssuedLottos(); + + const { winningNumber, bonusNumber } = await this.getWinningNumbers(); + + const rankCounts = RankCalculator.calculate( + this.#issuedLottos, + winningNumber, + bonusNumber + ); + + StatisticsView.printResult(rankCounts, purchaseAmount); + } } export default App; diff --git a/src/Lotto.js b/src/Lotto.js deleted file mode 100644 index cb0b1527e..000000000 --- a/src/Lotto.js +++ /dev/null @@ -1,18 +0,0 @@ -class Lotto { - #numbers; - - constructor(numbers) { - this.#validate(numbers); - this.#numbers = numbers; - } - - #validate(numbers) { - if (numbers.length !== 6) { - throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); - } - } - - // TODO: 추가 기능 구현 -} - -export default Lotto; diff --git a/src/model/Lotto.js b/src/model/Lotto.js new file mode 100644 index 000000000..56a4a7c90 --- /dev/null +++ b/src/model/Lotto.js @@ -0,0 +1,35 @@ +import { ERROR_MESSAGE, LOTTO_RULES } from '../utils/constants.js'; + +class Lotto { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = numbers; + } + + #validate(numbers) { + if (numbers.length !== LOTTO_RULES.TICKET_NUMBER_COUNT) { + throw new Error(ERROR_MESSAGE.GENERATED_LOTTO_COUNT); + } + + if (new Set(numbers).size !== numbers.length) { + throw new Error(ERROR_MESSAGE.GENERATED_LOTTO_DUPLICATE); + } + } + + getNumbers() { + return [...this.#numbers]; + } + + calculateMatchCount(winningNumber) { + return this.#numbers.filter((number) => winningNumber.includes(number)) + .length; + } + + hasBonusNumber(bonusNumber) { + return this.#numbers.includes(Number(bonusNumber)); + } +} + +export default Lotto; diff --git a/src/model/LottoMachine.js b/src/model/LottoMachine.js new file mode 100644 index 000000000..7dbc4d869 --- /dev/null +++ b/src/model/LottoMachine.js @@ -0,0 +1,49 @@ +import { Console, MissionUtils } from '@woowacourse/mission-utils'; +import { IO_MESSAGE, LOTTO_RULES } from '../utils/constants.js'; +import Parser from '../utils/Parser.js'; +import Lotto from './Lotto.js'; + +class LottoMachine { + /** + * 구입 가능한 로또 티켓 수 계산 + */ + static #determineLottoNumber(purchaseAmount) { + return Parser.getPurchaseCount(purchaseAmount); + } + + /** + * 한 개의 로또 번호 생성 (e.g. [8, 21, 23, 41, 42, 43]) + */ + static #generateLottoNumbers() { + return MissionUtils.Random.pickUniqueNumbersInRange( + LOTTO_RULES.MIN_NUMBER, + LOTTO_RULES.MAX_NUMBER, + LOTTO_RULES.TICKET_NUMBER_COUNT + ); + } + + /** + * 로또 티켓 발행 + */ + static #issueLottoTickets(purchaseCount) { + const issuedLottos = []; + + Array.from({ length: purchaseCount }).forEach(() => { + const lottoNumbers = this.#generateLottoNumbers(); + const lotto = new Lotto(lottoNumbers); + + issuedLottos.push(lotto); + }); + + return issuedLottos; + } + + static run(purchaseAmount) { + const purchaseCount = this.#determineLottoNumber(purchaseAmount); + Console.print(IO_MESSAGE.PURCHASE_COUNT_OUTPUT(purchaseCount)); + + return this.#issueLottoTickets(purchaseCount); + } +} + +export default LottoMachine; diff --git a/src/model/RankCalculator.js b/src/model/RankCalculator.js new file mode 100644 index 000000000..49c8a5bd3 --- /dev/null +++ b/src/model/RankCalculator.js @@ -0,0 +1,41 @@ +import { LOTTO_RULES } from '../utils/constants.js'; + +class RankCalculator { + /** + * 일치 결과(일치 개수, 보너스 여부)를 토대로 순위 배열의 인덱스로 반환 + * @param {number} matchCount - 일치한 번호의 개수 + * @param {boolean} hasBonus - 보너스 번호 일치 여부 + * @returns {number | undefined} 통계 배열의 인덱스 (0: 1등, 1: 2등, ... 4: 5등). 꽝은 undefined. + */ + static #getRankIndex(matchCount, hasBonus) { + if (matchCount === 5) return hasBonus ? 1 : 2; // 1: 2등 인덱스, 2: 3등 인덱스 + + // 일치 개수 : 랭크 인덱스 + const RANK_INDEX_MAP = { + 6: 0, // 1등 + 4: 3, // 4등 + 3: 4, // 5등 + }; + + return RANK_INDEX_MAP[matchCount]; + } + + static calculate(issuedLottos, winningNumber, bonusNumber) { + // [1등, 2등, 3등, 4등, 5등] 순서의 당첨 통계 배열 + // (예: [0, 0, 1, 0, 1] => 3등 1개, 5등 1개) + const rankCounts = new Array(LOTTO_RULES.TOTAL_RANK_COUNT).fill(0); + + issuedLottos.forEach((lotto) => { + const matchCount = lotto.calculateMatchCount(winningNumber); + const hasBonus = lotto.hasBonusNumber(bonusNumber); + + const rankIndex = this.#getRankIndex(matchCount, hasBonus); + + if (rankIndex !== undefined) rankCounts[rankIndex] += 1; + }); + + return rankCounts; + } +} + +export default RankCalculator; diff --git a/src/utils/InputValidator.js b/src/utils/InputValidator.js new file mode 100644 index 000000000..034d4dae2 --- /dev/null +++ b/src/utils/InputValidator.js @@ -0,0 +1,115 @@ +import { ERROR_MESSAGE, LOTTO_RULES, SEPERATOR, TERMS } from './constants.js'; +import Parser from './Parser.js'; + +/** + * 구입 금액 + */ +class PurchaseAmountValidator { + static #validators = [this.#validateIsNaN, this.#validateIsValidUnit]; + + static #validateIsNaN(value) { + if (Number.isNaN(Number(value))) { + throw new Error(ERROR_MESSAGE.PURCHASE_AMOUNT_TYPE); + } + } + + static #validateIsValidUnit(value) { + if (value % LOTTO_RULES.TICKET_PRICE !== 0) { + throw new Error(ERROR_MESSAGE.PURCHASE_AMOUNT_UNIT); + } + } + + static validate(value) { + this.#validators.forEach((validator) => validator.call(this, value)); + } +} + +/** + * 당첨 번호 + */ +class WinningNumberValidator { + static #validators = [ + this.#validateHasNaN, + this.#validateDuplicate, + this.#validateLength, + this.#validateRange, + ]; + + /** + * 숫자가 아닌 문자가 포함된 경우, 구분자가 쉼표(,)가 아닌 경우 검증 + */ + static #validateHasNaN(value) { + if (value.some((number) => Number.isNaN(number))) { + throw new Error(ERROR_MESSAGE.WINNING_NUMBER_TYPE); + } + } + + static #validateLength(value) { + if (value.length !== LOTTO_RULES.TICKET_NUMBER_COUNT) { + throw new Error(ERROR_MESSAGE.WINNING_NUMBER_LENGTH); + } + } + + static #validateDuplicate(value) { + if (new Set(value).size !== value.length) { + throw new Error(ERROR_MESSAGE.WINNING_NUMBER_DUPLICATE); + } + } + + static #validateRange(value) { + if ( + value.some((number) => number < LOTTO_RULES.MIN_NUMBER) || + value.some((number) => number > LOTTO_RULES.MAX_NUMBER) + ) { + throw new Error(ERROR_MESSAGE.WINNING_NUMBER_RANGE); + } + } + + static validate(value) { + const parsedValue = Parser.convertToNumberArray(value, SEPERATOR.COMMA); + + this.#validators.forEach((validator) => validator.call(this, parsedValue)); + } +} + +/** + * 보너스 번호 + */ +class BonusNumberValidator { + static #validateIsNaN(value) { + if (Number.isNaN(Number(value))) { + throw new Error(ERROR_MESSAGE.BONUS_NUMBER_TYPE); + } + } + + static validate(value) { + this.#validateIsNaN(value); + } +} + +/** + * 입력값 검증 + */ +class InputValidator { + static #validators = { + [TERMS.PURCHASE_AMOUNT]: (value) => PurchaseAmountValidator.validate(value), + [TERMS.WINNING_NUMBER]: (value) => WinningNumberValidator.validate(value), + [TERMS.BONUS_NUMBER]: (value) => BonusNumberValidator.validate(value), + }; + + // 공통 검증 로직 + static #commonValidate(input) { + if (input.trim().length === 0) { + throw new Error(ERROR_MESSAGE.BLANK_INPUT); + } + } + + static runValidate(key, value) { + InputValidator.#commonValidate(value); + + const validator = this.#validators[key]; + validator(value); + } +} + +export default InputValidator; diff --git a/src/utils/Parser.js b/src/utils/Parser.js new file mode 100644 index 000000000..cc5759eec --- /dev/null +++ b/src/utils/Parser.js @@ -0,0 +1,29 @@ +import { LOTTO_RULES } from './constants.js'; + +class Parser { + /** + * 구입 금액으로 구입가능한 로또 티켓 수 계산 + * @param {string} purchaseAmount - 구입 금액 문자열 (e.g. '5000') + */ + static getPurchaseCount(purchaseAmount) { + return Number(purchaseAmount) / LOTTO_RULES.TICKET_PRICE; + } + + /** + * 문자열을 구분자로 분리하여 숫자 배열로 변환 + * @param {string} input - 입력 문자열 + * @param {string} seperator - 구분자 + * @returns + */ + static convertToNumberArray(input, seperator) { + return input.split(seperator).map((number) => { + const trimmed = number.trim(); + + if (trimmed === '') return NaN; + + return Number(trimmed); + }); + } +} + +export default Parser; diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 000000000..a2b4e169e --- /dev/null +++ b/src/utils/constants.js @@ -0,0 +1,45 @@ +export const IO_MESSAGE = Object.freeze({ + PURCHASE_AMOUNT_INPUT: '구입금액을 입력해 주세요.\n', + WINNING_NUMBER_INPUT: '\n당첨 번호를 입력해 주세요.\n', + BONUS_NUMBER_INPUT: '\n보너스 번호를 입력해 주세요.\n', + + PURCHASE_COUNT_OUTPUT: (count) => `\n${count}개를 구매했습니다.`, + WINNING_STATISTICS_OUTPUT: '\n당첨 통계\n---\n', + TOTAL_PROFIT_OUTPUT: (profitRate) => + `총 수익률은 ${profitRate.toFixed(1)}%입니다.`, +}); + +export const ERROR_MESSAGE = Object.freeze({ + BLANK_INPUT: '[ERROR] 입력값이 비어 있습니다.', + + PURCHASE_AMOUNT_UNIT: '[ERROR] 구입 금액은 1,000원 단위여야 합니다.', + PURCHASE_AMOUNT_TYPE: '[ERROR] 구입 금액은 숫자여야 합니다.', + + GENERATED_LOTTO_COUNT: '[ERROR] 로또 번호는 6개여야 합니다.', + GENERATED_LOTTO_DUPLICATE: '[ERROR] 로또 번호에 중복된 숫자가 있습니다.', + + WINNING_NUMBER_LENGTH: '[ERROR] 당첨 번호는 6개여야 합니다.', + WINNING_NUMBER_TYPE: '[ERROR] 당첨 번호의 입력 형식이 올바르지 않습니다.', + WINNING_NUMBER_DUPLICATE: '[ERROR] 당첨 번호에 중복된 숫자가 있습니다.', + WINNING_NUMBER_RANGE: '[ERROR] 당첨 번호는 1부터 45 사이의 숫자여야 합니다.', + + BONUS_NUMBER_TYPE: '[ERROR] 보너스 번호는 숫자여야 합니다.', +}); + +export const LOTTO_RULES = Object.freeze({ + TICKET_PRICE: 1000, + MIN_NUMBER: 1, + MAX_NUMBER: 45, + TICKET_NUMBER_COUNT: 6, + TOTAL_RANK_COUNT: 5, +}); + +export const SEPERATOR = Object.freeze({ + COMMA: ',', +}); + +export const TERMS = Object.freeze({ + PURCHASE_AMOUNT: 'purchaseAmount', + WINNING_NUMBER: 'winningNumber', + BONUS_NUMBER: 'bonusNumber', +}); diff --git a/src/view/Input.js b/src/view/Input.js new file mode 100644 index 000000000..998d67a1a --- /dev/null +++ b/src/view/Input.js @@ -0,0 +1,29 @@ +import { Console } from '@woowacourse/mission-utils'; +import InputValidator from '../utils/InputValidator.js'; + +class Input { + /** + * 올바른 입력을 받을 때까지 반복해서 입력을 요청 + */ + static async readValidInput(key, message) { + try { + const input = await Console.readLineAsync(message); + InputValidator.runValidate(key, input); + + return input; + } catch (err) { + Console.print(err.message); + return Input.readValidInput(key, message); + } + } + + /** + * @param {string} key - 검증 키 (e.g. 'purchaseAmount', 'winningNumber') + * @param {string} message - 입력 요청 메시지 + */ + static readInputValues(key, message) { + return Input.readValidInput(key, message); + } +} + +export default Input; diff --git a/src/view/StatisticsView.js b/src/view/StatisticsView.js new file mode 100644 index 000000000..0c2528f81 --- /dev/null +++ b/src/view/StatisticsView.js @@ -0,0 +1,61 @@ +import { Console } from '@woowacourse/mission-utils'; +import { IO_MESSAGE } from '../utils/constants.js'; + +const PRIZE_INFO = [ + { description: '6개 일치', prize: 2_000_000_000 }, + { description: '5개 일치, 보너스 볼 일치', prize: 30_000_000 }, + { description: '5개 일치', prize: 1_500_000 }, + { description: '4개 일치', prize: 50_000 }, + { description: '3개 일치', prize: 5_000 }, +]; + +class StatisticsView { + /** + * 당첨 통계 헤더를 출력합니다. + */ + static #printStatisticsHeader() { + Console.print(IO_MESSAGE.WINNING_STATISTICS_OUTPUT); + } + + /** + * 개별 당첨 내역을 출력 + */ + static #printRankDetails(rankCounts) { + // 5등(index 4)부터 1등(index 0) 순서로 출력하기 위해 역순으로 순회 + for (let i = PRIZE_INFO.length - 1; i >= 0; i--) { + const { description, prize } = PRIZE_INFO[i]; + const count = rankCounts[i]; + const prizeString = prize.toLocaleString('ko-KR'); + + Console.print(`${description} (${prizeString}원) - ${count}개`); + } + } + + /** + * 총 수익률을 계산하고 출력합니다. + */ + static #printProfitRate(rankCounts, purchaseAmount) { + // 총 상금 계산 + const totalProfit = rankCounts.reduce((sum, count, index) => { + return sum + count * PRIZE_INFO[index].prize; + }, 0); + + // 수익률 계산 ( (총상금 / 구매금액) * 100 ) + const profitRate = (totalProfit / purchaseAmount) * 100; + + Console.print(IO_MESSAGE.TOTAL_PROFIT_OUTPUT(profitRate)); + } + + /** + * 당첨 통계 전체 결과를 출력합니다. + * @param {number[]} rankCounts - [1등, 2등, 3등, 4등, 5등] 당첨 개수 배열 + * @param {number} purchaseAmount - 총 구매 금액 + */ + static printResult(rankCounts, purchaseAmount) { + this.#printStatisticsHeader(); + this.#printRankDetails(rankCounts); + this.#printProfitRate(rankCounts, purchaseAmount); + } +} + +export default StatisticsView;