diff --git a/README.md b/README.md index b168a180..a12d6c46 100644 --- a/README.md +++ b/README.md @@ -1 +1,60 @@ # javascript-planetlotto-precourse + +## 기능 체크리스트 + +#### 로또 구입 + +- [x] 구입 금액 입력 +- [x] 구입 금액에 맞게 로또 개수 발행 +- [x] 500 단위로 발행하되, 거스름돈 계산 수행 + +#### 로또 발행 + +- [x] 한 장당 1 ~ 30 사이 중복되지 않는 5개 번호 랜덤 발행 +- [x] 번호 오름차순 정렬 +- [x] 구매 개수 및 로또 번호 목록 출력 + +#### 당첨 번호 및 보너스 입력 + +- [x] 당첨 번호를 쉼표를 구분하여 입력 +- [x] 입력된 번호는 5개 +- [x] 각 번호는 1 ~ 30 사이의 정수 +- [x] 당첨 번호 이외 보너스 번호 입력 + +#### 당첨 결과 계산 + +- [x] 구매한 모든 로또 번호와 당첨 번호 비교 +- [x] 일치 개수 + 보너스 번호 포함 여부 계산 +- [x] 일치 개수와 보너스 일치 여부 기준으로 등수 판별 +- [x] 등수 갯수 집계 +- [x] 총 상금 합산 + +#### 추가 기능 + +- [x] 거스름돈 계산 +- [] 총 상금을 기반으로 로또 교환 여부 작성 +- [] Y or N 로 입력 받은 후 N 입력 시 프로그램 종료 + +## 예외 체크리스트 + +#### 구입 금액 관련 예외 + +- [x] 구입 금액이 숫자가 아님 +- [x] 500원 미만으로 입력했을 때 + +#### 당첨 번호 입력 관련 예외 + +- [x] 쉼표로 구분되지 않은 입력 -> '1 2 3 4 5' +- [x] 숫자 외 문자 포함 +- [x] 숫자 5개 미만 +- [x] 숫자 5개 초과 +- [x] 중복된 번호 존재 +- [x] 숫자 범위 초과 (1 미만 or 30 초과) +- [x] 공백 또는 빈 입력 + +#### 보너스 번호 입력 관련 예외 + +- [x] 숫자가 아닌 입력 +- [x] 범위 초과 (1 미만 or 30 초과) +- [x] 당첨 번호와 중복 +- [x] 공백 또는 빈 입력 diff --git a/src/App.js b/src/App.js index 091aa0a5..6b42acb5 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.controlLotto(); + } } export default App; diff --git a/src/constants/lotto.js b/src/constants/lotto.js new file mode 100644 index 00000000..23d3175a --- /dev/null +++ b/src/constants/lotto.js @@ -0,0 +1,45 @@ +export const LOTTO_PRICE = 500; +export const LOTTO_MIN_NUMBER = 1; +export const LOTTO_MAX_NUMBER = 30; +export const LOTTO_SIZE = 5; +export const LOTTO_DELIMITER = ","; +export const RANK_RULE = { + RANKS: [ + { + name: "FIRST", + matchCount: 5, + bonus: false, + prize: 100000000, + }, + { + name: "SECOND", + matchCount: 4, + bonus: true, + prize: 10000000, + }, + { + name: "THIRD", + matchCount: 4, + bonus: false, + prize: 1500000, + }, + { + name: "FOURTH", + matchCount: 3, + bonus: true, + prize: 500000, + }, + { + name: "FIFTH", + matchCount: 2, + bonus: true, + prize: 5000, + }, + { + name: "MISS", + matchCount: 0, + bonus: false, + prize: 0, + }, + ], +}; diff --git a/src/constants/message.js b/src/constants/message.js new file mode 100644 index 00000000..85b0e0d1 --- /dev/null +++ b/src/constants/message.js @@ -0,0 +1,9 @@ +export const ERROR_MESSAGE = { + EMPTY_INPUT: "입력값이 비어 있습니다.", + INVALID_PURCHASE_MIN: "구입 금액은 500원 이상이어야 합니다.", + INVALID_LOTTO_NUMBER: "로또 번호는 1부터 30 사이의 숫자여야 합니다.", + INVALID_LOTTO_SIZE: "로또 번호는 5개여야 합니다.", + DUPLICATED_NUMBER: "로또 번호는 중복된 숫자가 발생할 수 없습니다.", + DUPLICATED_LOTTO_AND_BONUS_NUMBER: + "로또 번호와 보너스 번호는 중복될 수 없습니다.", +}; diff --git a/src/controller/LottoController.js b/src/controller/LottoController.js new file mode 100644 index 00000000..32e05998 --- /dev/null +++ b/src/controller/LottoController.js @@ -0,0 +1,84 @@ +import WinningLotto from "../model/WinningLotto.js"; +import LottoService from "../service/LottoService.js"; +import LottoValidator from "../service/LottoValidator.js"; +import { InputView, OutputView } from "../view.js"; + +class LottoController { + #inputView; + #outputView; + #validator; + #lottoService; + constructor() { + this.#inputView = InputView; + this.#outputView = OutputView; + this.#validator = new LottoValidator(); + this.#lottoService = new LottoService(); + } + + async controlLotto() { + const amount = await this.#inputAmount(); + + const { change, lottos } = this.#lottoService.publish(amount); + this.#printPurchaseResult(amount, change, lottos); + + const winningNumbers = await this.#inputWinningNumbers(); + const bonusNumber = await this.#inputBonusNumber(winningNumbers); + + const winningLotto = new WinningLotto(winningNumbers, bonusNumber); + const { resultMap } = this.#lottoService.fillMapResult( + lottos, + winningLotto + ); + + this.#outputView.printResult(resultMap); + } + + #printPurchaseResult(amount, change, lottos) { + const lottoArray = []; + for (let lotto of lottos) { + lottoArray.push(lotto.getNumbers()); + } + this.#outputView.printPurchasedLottos(lottoArray); + if (change !== 0) { + this.#outputView.printChangeMessage(amount, change); + } + } + + async #inputAmount() { + while (true) { + try { + const amount = await this.#inputView.askAmount(); + this.#validator.validateAmount(amount); + return amount; + } catch (error) { + this.#outputView.printErrorMessage(error.message); + } + } + } + + async #inputWinningNumbers() { + while (true) { + try { + const winningNumbers = await this.#inputView.askWinningLotto(); + this.#validator.validateWinningNumbers(winningNumbers); + return winningNumbers; + } catch (error) { + this.#outputView.printErrorMessage(error.message); + } + } + } + + async #inputBonusNumber(winningNumbers) { + while (true) { + try { + const bonusNumber = await this.#inputView.askBonusNumber(); + this.#validator.validateBonusNumber(winningNumbers, bonusNumber); + return bonusNumber; + } catch (error) { + this.#outputView.printErrorMessage(error.message); + } + } + } +} + +export default LottoController; diff --git a/src/factory/RankFactory.js b/src/factory/RankFactory.js new file mode 100644 index 00000000..20be2da7 --- /dev/null +++ b/src/factory/RankFactory.js @@ -0,0 +1,14 @@ +import Rank from "../model/Rank.js"; + +class RankFactory { + static createRank(matchCount, bonusMatch) { + if (matchCount === 5) return Rank.FIRST; + if (matchCount === 4 && bonusMatch) return Rank.SECOND; + if (matchCount === 4) return Rank.THIRD; + if (matchCount === 3 && bonusMatch) return Rank.FOURTH; + if (matchCount === 2 && bonusMatch) return Rank.FIFTH; + if (matchCount === 0) return Rank.MISS; + } +} + +export default RankFactory; diff --git a/src/model/Lotto.js b/src/model/Lotto.js new file mode 100644 index 00000000..2030378a --- /dev/null +++ b/src/model/Lotto.js @@ -0,0 +1,40 @@ +import { LOTTO_SIZE } from "../constants/lotto.js"; +import { ERROR_MESSAGE } from "../constants/message.js"; +import { hasNoDuplicates } from "../utils/validator.js"; + +class Lotto { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = numbers; + } + + #validate(numbers) { + if (numbers.length !== LOTTO_SIZE) { + throw new Error(ERROR_MESSAGE.INVALID_LOTTO_SIZE); + } + + if (!hasNoDuplicates(numbers)) { + throw new DefaultError(ERROR_MESSAGE.DUPLICATED_NUMBER); + } + } + + getNumbers() { + return [...this.#numbers]; + } + + matchCount(winningNumbers) { + const matchNumbers = this.#numbers.filter((number) => + winningNumbers.includes(number) + ); + + return matchNumbers.length; + } + + contains(number) { + return this.#numbers.includes(number); + } +} + +export default Lotto; diff --git a/src/model/LottoResult.js b/src/model/LottoResult.js new file mode 100644 index 00000000..4e72f284 --- /dev/null +++ b/src/model/LottoResult.js @@ -0,0 +1,39 @@ +import { RANK_RULE } from "../constants/lotto.js"; +import RankFactory from "../factory/RankFactory.js"; + +class LottoResult { + #counts; + constructor() { + this.#counts = this.#initCounts(); + } + + #initCounts() { + const counts = new Map(); + RANK_RULE.RANKS.forEach((rankRule) => { + const rank = RankFactory.createRank(rankRule.matchCount, rankRule.bonus); + counts.set(rank, 0); + }); + + return counts; + } + + add(rank) { + this.#counts.set(rank, this.#counts.get(rank) + 1); + } + + getCountOf(rank) { + return this.#counts.get(rank) ?? 0; + } + + getTotalPrize() { + let sum = 0; + + for (const [rank, count] of this.#counts.entries()) { + sum += rank.prize * count; + } + + return sum; + } +} + +export default LottoResult; diff --git a/src/model/Rank.js b/src/model/Rank.js new file mode 100644 index 00000000..763d0af2 --- /dev/null +++ b/src/model/Rank.js @@ -0,0 +1,30 @@ +import { RANK_RULE } from "../constants/lotto.js"; + +class Rank { + #name; + #matchCount; + #bonus; + #prize; + constructor({ name, matchCount, bonus, prize }) { + this.#name = name; + this.#matchCount = matchCount; + this.#bonus = bonus; + this.#prize = prize; + } + + static initializeRanks() { + const normalRanks = this.#createNormalRanks(); + + normalRanks.forEach((rank) => { + Rank[rank.#name] = rank; + }); + } + + static #createNormalRanks() { + return RANK_RULE.RANKS.map((rule) => new Rank(rule)); + } +} + +Rank.initializeRanks(); + +export default Rank; diff --git a/src/model/WinningLotto.js b/src/model/WinningLotto.js new file mode 100644 index 00000000..d222ca50 --- /dev/null +++ b/src/model/WinningLotto.js @@ -0,0 +1,27 @@ +import { ERROR_MESSAGE } from "../constants/message.js"; + +class WinningLotto { + #winningNumbers; + #bonusNumber; + + constructor(winningNumbers, bonusNumber) { + this.#validate(winningNumbers, bonusNumber); + this.#winningNumbers = winningNumbers; + this.#bonusNumber = bonusNumber; + } + + #validate(winningNumbers, bonusNumber) { + if (winningNumbers.includes(bonusNumber)) { + throw new Error(ERROR_MESSAGE.DUPLICATED_LOTTO_AND_BONUS_NUMBER); + } + } + + match(lotto) { + const matchCount = lotto.matchCount(this.#winningNumbers); + const bonusMatch = lotto.contains(this.#bonusNumber); + + return { matchCount, bonusMatch }; + } +} + +export default WinningLotto; diff --git a/src/service/LottoService.js b/src/service/LottoService.js new file mode 100644 index 00000000..adce1649 --- /dev/null +++ b/src/service/LottoService.js @@ -0,0 +1,103 @@ +import { + LOTTO_MAX_NUMBER, + LOTTO_MIN_NUMBER, + LOTTO_PRICE, + LOTTO_SIZE, + RANK_RULE, +} from "../constants/lotto.js"; +import Lotto from "../model/Lotto.js"; +import { generateSortedRandomNubmers } from "../utils/randomGenerator.js"; +import LottoResult from "../model/LottoResult.js"; +import RankFactory from "../factory/RankFactory.js"; + +class LottoService { + publish(amount) { + const { count, change } = this.#calculateLottoCount(amount); + const lottos = Array.from({ length: count }, () => { + return this.#createSingleLotto(); + }); + + return { change, lottos }; + } + + #parseRankRule(rankRule) { + if (rankRule.name === "FIRST") { + return 1; + } + if (rankRule.name === "SECOND") { + return 2; + } + if (rankRule.name === "THIRD") { + return 3; + } + if (rankRule.name === "FOURTH") { + return 4; + } + if (rankRule.name === "FIFTH") { + return 5; + } + if (rankRule.name === "MISS") { + return 0; + } + } + + fillMapResult(lottos, winningLotto) { + const resultMap = new Map(); + const lottoResult = this.fillLottoResult(lottos, winningLotto); + + RANK_RULE.RANKS.forEach((rankRule) => { + resultMap.set( + this.#parseRankRule(rankRule), + lottoResult.getCountOf( + RankFactory.createRank(rankRule.matchCount, rankRule.bonus) + ) + ); + }); + + return { resultMap, lottoResult }; + } + + fillLottoResult(lottos, winningLotto) { + const result = this.#createEmptyResult(); + + lottos.forEach((lotto) => { + const rank = this.#evaluateSingleLotto(lotto, winningLotto); + this.#accumulate(result, rank); + }); + + return result; + } + + #createEmptyResult() { + return new LottoResult(); + } + + #evaluateSingleLotto(lotto, winningLotto) { + const { matchCount, bonusMatch } = winningLotto.match(lotto); + + return RankFactory.createRank(matchCount, bonusMatch); + } + + #accumulate(result, rank) { + result.add(rank); + } + + #calculateLottoCount(amount) { + return { + count: Math.floor(amount / LOTTO_PRICE), + change: Math.floor(amount % LOTTO_PRICE), + }; + } + + #createSingleLotto() { + const numbers = generateSortedRandomNubmers( + LOTTO_MIN_NUMBER, + LOTTO_MAX_NUMBER, + LOTTO_SIZE + ); + + return new Lotto(numbers); + } +} + +export default LottoService; diff --git a/src/service/LottoValidator.js b/src/service/LottoValidator.js new file mode 100644 index 00000000..2ff884a6 --- /dev/null +++ b/src/service/LottoValidator.js @@ -0,0 +1,44 @@ +import { + LOTTO_MAX_NUMBER, + LOTTO_MIN_NUMBER, + LOTTO_PRICE, + LOTTO_SIZE, +} from "../constants/lotto.js"; +import { ERROR_MESSAGE } from "../constants/message.js"; +import { hasNoDuplicates, isInRange } from "../utils/validator.js"; + +class LottoValidator { + validateAmount(amount) { + if (amount < LOTTO_PRICE) { + throw new Error(ERROR_MESSAGE.INVALID_PURCHASE_MIN); + } + } + + validateWinningNumbers(numbers) { + if (numbers.length !== LOTTO_SIZE) { + throw new Error(ERROR_MESSAGE.INVALID_LOTTO_SIZE); + } + + numbers.forEach((number) => this.#validateLottoNumbers(number)); + + if (!hasNoDuplicates(numbers)) { + throw new ERROR(ERROR_MESSAGE.DUPLICATED_NUMBER); + } + } + + validateBonusNumber(winningNumbers, bonusNumber) { + this.#validateLottoNumbers(bonusNumber); + + if (winningNumbers.includes(bonusNumber)) { + throw new Error(ERROR_MESSAGE.DUPLICATED_LOTTO_AND_BONUS_NUMBER); + } + } + + #validateLottoNumbers(number) { + if (!isInRange(number, LOTTO_MIN_NUMBER, LOTTO_MAX_NUMBER)) { + throw new Error(ERROR_MESSAGE.INVALID_LOTTO_NUMBER); + } + } +} + +export default LottoValidator; diff --git a/src/utils/randomGenerator.js b/src/utils/randomGenerator.js new file mode 100644 index 00000000..47904fb4 --- /dev/null +++ b/src/utils/randomGenerator.js @@ -0,0 +1,8 @@ +import { Random } from "@woowacourse/mission-utils"; + +export function generateSortedRandomNubmers(minNumber, maxNumber, size) { + const numbers = Random.pickUniqueNumbersInRange(minNumber, maxNumber, size); + const sorted = numbers.sort((a, b) => a - b); + + return sorted; +} diff --git a/src/utils/validator.js b/src/utils/validator.js new file mode 100644 index 00000000..201edd12 --- /dev/null +++ b/src/utils/validator.js @@ -0,0 +1,7 @@ +export function isInRange(value, min, max) { + return value >= min && value <= max; +} + +export function hasNoDuplicates(array) { + return new Set(array).size === array.length; +} diff --git a/src/view.js b/src/view.js index ae6afd9c..6df91294 100644 --- a/src/view.js +++ b/src/view.js @@ -5,10 +5,12 @@ const InputView = { * @returns {number} */ async askAmount() { - const input = await MissionUtils.Console.readLineAsync('구입금액을 입력해 주세요.\n'); + const input = await MissionUtils.Console.readLineAsync( + "구입금액을 입력해 주세요.\n" + ); const num = parseInt(input, 10); if (Number.isNaN(num)) { - throw new Error('구매금액은 숫자여야 합니다.'); + throw new Error("구매금액은 숫자여야 합니다."); } return num; }, @@ -17,15 +19,17 @@ const InputView = { * @returns {number[]} */ async askWinningLotto() { - const input = await MissionUtils.Console.readLineAsync('지난 주 당첨 번호를 입력해 주세요.\n'); + const input = await MissionUtils.Console.readLineAsync( + "지난 주 당첨 번호를 입력해 주세요.\n" + ); return input - .replaceAll(' ', '') - .split(',') + .replaceAll(" ", "") + .split(",") .map((s) => { const n = parseInt(s, 10); if (Number.isNaN(n)) { - throw new Error('당첨 번호는 숫자여야 합니다.'); + throw new Error("당첨 번호는 숫자여야 합니다."); } return n; }); @@ -35,10 +39,12 @@ const InputView = { * @returns {number} */ async askBonusNumber() { - const input = await MissionUtils.Console.readLineAsync('보너스 번호를 입력해 주세요.\n'); + const input = await MissionUtils.Console.readLineAsync( + "보너스 번호를 입력해 주세요.\n" + ); const num = parseInt(input, 10); if (Number.isNaN(num)) { - throw new Error('보너스 번호는 숫자여야 합니다.'); + throw new Error("보너스 번호는 숫자여야 합니다."); } return num; }, @@ -51,9 +57,9 @@ const OutputView = { printPurchasedLottos(lottos) { const lines = [ `${lottos.length}개를 구매했습니다.`, - ...lottos.map(lotto => `[${lotto.join(', ')}]`), + ...lottos.map((lotto) => `[${lotto.join(", ")}]`), ]; - MissionUtils.Console.print(lines.join('\n')); + MissionUtils.Console.print(lines.join("\n")); }, /** @@ -66,15 +72,15 @@ const OutputView = { const getCount = (k) => countsByRank.get(k) ?? 0; const output = [ - '당첨 통계', - '---', + "당첨 통계", + "---", `5개 일치 (100,000,000원) - ${getCount(1)}개`, `4개 일치, 보너스 번호 일치 (10,000,000원) - ${getCount(2)}개`, `4개 일치 (1,500,000원) - ${getCount(3)}개`, `3개 일치, 보너스 번호 일치 (500,000원) - ${getCount(4)}개`, `2개 일치, 보너스 번호 일치 (5,000원) - ${getCount(5)}개`, `0개 일치 (0원) - ${getCount(0)}개`, - ].join('\n'); + ].join("\n"); MissionUtils.Console.print(output); }, @@ -85,6 +91,18 @@ const OutputView = { printErrorMessage(message) { MissionUtils.Console.print(`[ERROR] ${message}`); }, + + printChangeMessage(amount, change) { + MissionUtils.Console.print( + `\n${amount.toLocaleString()}원 중 ${change.toLocaleString()}원을 거슬러 드리겠습니다.\n` + ); + }, + + printTotalProfit(profit) { + MissionUtils.Console.print( + `총 상금은 ${profit.toLocaleString()}원 입니다.` + ); + }, }; export { InputView, OutputView };