diff --git a/README.md b/README.md index 15bb106b5..22e99d8af 100644 --- a/README.md +++ b/README.md @@ -1 +1,59 @@ -# javascript-lotto-precourse +## 1️⃣ 과제 개요 + +**우아한 테크코스 프리코스 3주차 과제** + +**과제명 : 로또** + +**기간 : 10.28 ~ 11.03 (3주차)** + +**작성자 : 윤돌** + +
+ +## 2️⃣ 기능 목록 + +**(0) 기본 구조 세팅** + +**(1) 로또 구입 금액 입력 기능 구현** + +**(2) 구입 금액 검증 로직 구현** + +- 숫자가 아닌 경우 예외처리 +- 0보다 작거나 같을 경우 예외처리 +- 1000 단위로 떨어지지 않을 경우 예외처리 + +**(3) 로또 발행 기능 구현** + +**(4) 저장 및 조회 가능한 LottoBundle 클래스 생성** + +**(5) 발행된 로또 번호 출력 기능 구현** + +**(6) 당첨 번호 입력 기능 구현** + +**(7) 당첨 번호 검증 로직 구현** + +- 6개가 아닌 경우 예외처리 +- 중복될 경우 예외처리 +- 숫자가 아닐 경우 예외처리 +- 1~45 사이가 아닐 경우 예외처리 + +**(8) 보너스 번호 입력 기능 구현** + +**(9) 보너스 번호 검증 기능 구현** + +- 숫자가 아닐 경우 예외처리 +- 1~45 사이가 아닐 경우 예외처리 + +**(10) 규칙 및 상금 정의** + +**(11) 당첨 결과 계산 기능 구현** + +**(12) 결과 구현(결과 집계 및 수익률 계산)** + +**(13) 결과 출력** + +**(14) 통합 테스트 확인** + +**(15) 단일 테스트 추가 및 확인** + +
diff --git a/__tests__/BonusNumberTest.js b/__tests__/BonusNumberTest.js new file mode 100644 index 000000000..e49d0a618 --- /dev/null +++ b/__tests__/BonusNumberTest.js @@ -0,0 +1,12 @@ +import Validator from "../src/util/Validator"; + +describe("validateBonusNumber", () => { + test("숫자가 아니면 예외가 발생한다.", () => { + expect(() => Validator.validateBonusNumber("abc")).toThrow("[ERROR]"); + }); + + test("1 ~ 45 범위를 벗어나면 예외가 발생한다.", () => { + expect(() => Validator.validateBonusNumber("0")).toThrow("[ERROR]"); + expect(() => Validator.validateBonusNumber("46")).toThrow("[ERROR]"); + }); +}); diff --git a/__tests__/LottoJudgeTest.js b/__tests__/LottoJudgeTest.js new file mode 100644 index 000000000..a979a15a1 --- /dev/null +++ b/__tests__/LottoJudgeTest.js @@ -0,0 +1,7 @@ +import LottoJudge from "../src/service/LottoJudge"; + +describe("로또 판단하는 클래스 테스트", () => { + test("보너스 번호가 당첨 번호에 포함되어 있으면 에러를 던진다.", () => { + expect(() => new LottoJudge([1, 2, 3, 4, 5, 6], 6)).toThrow("[ERROR]"); + }); +}); diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js index 409aaf69b..a1636dee2 100644 --- a/__tests__/LottoTest.js +++ b/__tests__/LottoTest.js @@ -1,4 +1,4 @@ -import Lotto from "../src/Lotto"; +import Lotto from "../src/model/Lotto"; describe("로또 클래스 테스트", () => { test("로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.", () => { @@ -15,4 +15,14 @@ describe("로또 클래스 테스트", () => { }); // TODO: 추가 기능 구현에 따른 테스트 코드 작성 + test("숫자가 아니면 예외가 발생한다.", () => { + expect(() => { + new Lotto([1, 2, 3, 4, 5, NaN]); + }).toThrow("[ERROR]"); + }); + + test("1 ~ 45 범위를 벗어나면 예외가 발생한다.", () => { + expect(() => new Lotto([0, 2, 3, 4, 5, 6])).toThrow("[ERROR]"); + expect(() => new Lotto([1, 2, 3, 4, 5, 46])).toThrow("[ERROR]"); + }); }); diff --git a/__tests__/PurchaseAmountTest.js b/__tests__/PurchaseAmountTest.js new file mode 100644 index 000000000..2948c4e52 --- /dev/null +++ b/__tests__/PurchaseAmountTest.js @@ -0,0 +1,17 @@ +import Validator from "../src/util/Validator"; + +describe("구입 금액 검증 테스트", () => { + test("숫자가 아니면 예외가 발생한다.", () => { + expect(() => Validator.validatePurchaseAmount("abc")).toThrow("[ERROR]"); + }); + + test("0 이하면 예외가 발생한다.", () => { + expect(() => Validator.validatePurchaseAmount("0")).toThrow("[ERROR]"); + expect(() => Validator.validatePurchaseAmount("-1000")).toThrow("[ERROR]"); + }); + + test("1000 단위가 아니면 예외가 발생한다.", () => { + expect(() => Validator.validatePurchaseAmount("1500")).toThrow("[ERROR]"); + expect(() => Validator.validatePurchaseAmount("2999")).toThrow("[ERROR]"); + }); +}); diff --git a/__tests__/WinningNumbersTest.js b/__tests__/WinningNumbersTest.js new file mode 100644 index 000000000..d2050935b --- /dev/null +++ b/__tests__/WinningNumbersTest.js @@ -0,0 +1,38 @@ +import Validator from "../src/util/Validator"; + +describe("당첨 번호 검증 테스트", () => { + test("개수가 6개가 아니면 예외가 발생한다.", () => { + expect(() => + Validator.validateWinningNumbers([1, 2, 3, 4, 5, 6, 7]) + ).toThrow("[ERROR]"); + expect(() => Validator.validateWinningNumbers([1, 2, 3, 4, 5])).toThrow( + "[ERROR]" + ); + }); + + test("중복이 있으면 예외가 발생한다.", () => { + expect(() => Validator.validateWinningNumbers([1, 2, 3, 4, 5, 5])).toThrow( + "[ERROR]" + ); + }); + + test("숫자가 아니면 예외가 발생한다.", () => { + // 문자열 'a' 포함 + expect(() => + Validator.validateWinningNumbers(["a", 2, 3, 4, 5, 6]) + ).toThrow("[ERROR]"); + // NaN 직접 포함 + expect(() => + Validator.validateWinningNumbers([NaN, 2, 3, 4, 5, 6]) + ).toThrow("[ERROR]"); + }); + + test("1 ~ 45 범위를 벗어나면 예외가 발생한다.", () => { + expect(() => Validator.validateWinningNumbers([0, 2, 3, 4, 5, 6])).toThrow( + "[ERROR]" + ); + expect(() => Validator.validateWinningNumbers([1, 2, 3, 4, 5, 46])).toThrow( + "[ERROR]" + ); + }); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5d..3b4f69097 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,37 @@ +import { Console } from "@woowacourse/mission-utils"; +import LottoBundle from "./model/LottoBundle.js"; +import Result from "./model/Result.js"; +import LottoJudge from "./service/LottoJudge.js"; +import LottoMachine from "./service/LottoMachine.js"; +import InputView from "./view/InputView.js"; +import OutputView from "./view/OutputView.js"; + class App { - async run() {} + async run() { + try { + const purchaseAmount = await InputView.readPurchaseAmount(); + const lottoMachine = new LottoMachine(purchaseAmount); + const lottoBundle = new LottoBundle(lottoMachine.getLottos()); + + OutputView.printLottoBundle(lottoBundle); + + const winningNumbers = await InputView.readWinningNumbers(); + OutputView.printLineBreak(); + const bonusNumber = await InputView.readBonusNumber(); + + const lottoJudge = new LottoJudge(winningNumbers, bonusNumber); + const result = new Result(); + + result.calculate(lottoBundle, lottoJudge); + const rankCounts = result.getRankCounts(); + const profitRate = result.getProfitRate(purchaseAmount); + + OutputView.printResult(rankCounts); + OutputView.printProfitRate(profitRate); + } catch (error) { + Console.print(error.message); + } + } } 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..c1a5c9c84 --- /dev/null +++ b/src/model/Lotto.js @@ -0,0 +1,36 @@ +class Lotto { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = numbers; + } + + #validate(numbers) { + if (numbers.length !== 6) { + throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); + } + + const uniqueNumbers = new Set(numbers); + if (uniqueNumbers.size !== numbers.length) { + throw new Error("[ERROR] 로또 번호는 중복될 수 없습니다."); + } + + numbers.forEach((num) => { + if (isNaN(num)) { + throw new Error("[ERROR] 로또 번호는 숫자만 입력해야 합니다."); + } + + if (num < 1 || num > 45) { + throw new Error("[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다."); + } + }); + } + + // TODO: 추가 기능 구현 + getNumbers() { + return this.#numbers; + } +} + +export default Lotto; diff --git a/src/model/LottoBundle.js b/src/model/LottoBundle.js new file mode 100644 index 000000000..5e2019027 --- /dev/null +++ b/src/model/LottoBundle.js @@ -0,0 +1,21 @@ +class LottoBundle { + #lottoBundle; + + constructor(lottoBundle) { + this.#lottoBundle = lottoBundle; + } + + size() { + return this.#lottoBundle.length; + } + + getAll() { + return [...this.#lottoBundle]; + } + + forEach(callback) { + this.#lottoBundle.forEach(callback); + } +} + +export default LottoBundle; diff --git a/src/model/Rank.js b/src/model/Rank.js new file mode 100644 index 000000000..40bfc147e --- /dev/null +++ b/src/model/Rank.js @@ -0,0 +1,30 @@ +export const RANKS = { + FIRST: 1, + SECOND: 2, + THIRD: 3, + FOURTH: 4, + FIFTH: 5, +}; + +export const PRIZES = { + [RANKS.FIRST]: 2000000000, + [RANKS.SECOND]: 30000000, + [RANKS.THIRD]: 1500000, + [RANKS.FOURTH]: 50000, + [RANKS.FIFTH]: 5000, +}; + +export function getRank(matchCount, hasBonus) { + if (matchCount === 6) + return { rank: RANKS.FIRST, prize: PRIZES[RANKS.FIRST] }; + if (matchCount === 5 && hasBonus) + return { rank: RANKS.SECOND, prize: PRIZES[RANKS.SECOND] }; + if (matchCount === 5) + return { rank: RANKS.THIRD, prize: PRIZES[RANKS.THIRD] }; + if (matchCount === 4) + return { rank: RANKS.FOURTH, prize: PRIZES[RANKS.FOURTH] }; + if (matchCount === 3) + return { rank: RANKS.FIFTH, prize: PRIZES[RANKS.FIFTH] }; + + return null; +} diff --git a/src/model/Result.js b/src/model/Result.js new file mode 100644 index 000000000..1f7f5d135 --- /dev/null +++ b/src/model/Result.js @@ -0,0 +1,36 @@ +import { PRIZES } from "./Rank.js"; + +class Result { + constructor() { + this.rankCounts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; + } + + addRank(rank) { + if (rank) this.rankCounts[rank.rank]++; + } + + calculate(lottoBundle, lottoJudge) { + lottoBundle.forEach((lotto) => { + const rank = lottoJudge.judge(lotto); + this.addRank(rank); + }); + } + + getTotalPrize() { + return Object.entries(this.rankCounts).reduce((total, [rank, count]) => { + const prize = PRIZES[rank] ?? 0; + return total + prize * count; + }, 0); + } + + getProfitRate(purchaseAmount) { + const totalPrize = this.getTotalPrize(); + return ((totalPrize / purchaseAmount) * 100).toFixed(1); + } + + getRankCounts() { + return this.rankCounts; + } +} + +export default Result; diff --git a/src/service/LottoJudge.js b/src/service/LottoJudge.js new file mode 100644 index 000000000..e202c2eb7 --- /dev/null +++ b/src/service/LottoJudge.js @@ -0,0 +1,27 @@ +import { getRank } from "../model/Rank.js"; + +class LottoJudge { + constructor(winningNumbers, bonusNumber) { + this.#validate(winningNumbers, bonusNumber); + this.winningNumbers = winningNumbers; + this.bonusNumber = bonusNumber; + } + + #validate(winningNumbers, bonusNumber) { + if (winningNumbers.includes(bonusNumber)) { + throw new Error("[ERROR] 보너스 번호는 당첨 번호와 중복될 수 없습니다."); + } + } + + judge(lotto) { + const numbers = lotto.getNumbers(); + const matchCount = numbers.filter((num) => + this.winningNumbers.includes(num) + ).length; + const hasBonus = numbers.includes(this.bonusNumber); + + return getRank(matchCount, hasBonus); + } +} + +export default LottoJudge; diff --git a/src/service/LottoMachine.js b/src/service/LottoMachine.js new file mode 100644 index 000000000..01b2ba4ba --- /dev/null +++ b/src/service/LottoMachine.js @@ -0,0 +1,32 @@ +import { MissionUtils } from "@woowacourse/mission-utils"; +import Lotto from "../model/Lotto.js"; + +class LottoMachine { + #lottoBundle; + + constructor(purchaseAmount) { + this.lottoCount = purchaseAmount / 1000; + this.#lottoBundle = this.#generateLottos(); + } + + #generateLottos() { + const lottoBundle = []; + for (let i = 0; i < this.lottoCount; i++) { + const numbers = this.#generateRandomNumbers(); + lottoBundle.push(new Lotto(numbers)); + } + + return lottoBundle; + } + + #generateRandomNumbers() { + const numbers = MissionUtils.Random.pickUniqueNumbersInRange(1, 45, 6); + return numbers.sort((a, b) => a - b); + } + + getLottos() { + return this.#lottoBundle; + } +} + +export default LottoMachine; diff --git a/src/util/Validator.js b/src/util/Validator.js new file mode 100644 index 000000000..a792b6396 --- /dev/null +++ b/src/util/Validator.js @@ -0,0 +1,54 @@ +const Validator = { + validatePurchaseAmount(input) { + const amount = Number(input); + + if (isNaN(amount)) { + throw new Error("[ERROR] 구입 금액은 숫자여야 합니다."); + } + + if (amount <= 0) { + throw new Error("[ERROR] 구입 금액은 0보다 커야 합니다."); + } + + if (amount % 1000 !== 0) { + throw new Error("[ERROR] 구입 금액은 1,000원 단위로 입력해야 합니다."); + } + + return amount; + }, + + validateWinningNumbers(winningNumbers) { + if (winningNumbers.length !== 6) { + throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); + } + + const uniqueNumbers = new Set(winningNumbers); + if (uniqueNumbers.size !== winningNumbers.length) { + throw new Error("[ERROR] 로또 번호는 중복될 수 없습니다."); + } + + winningNumbers.forEach((num) => { + if (isNaN(num)) { + throw new Error("[ERROR] 로또 번호는 숫자만 입력해야 합니다."); + } + + if (num < 1 || num > 45) { + throw new Error("[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다."); + } + }); + }, + + validateBonusNumber(bonusNumber) { + const number = Number(bonusNumber); + + if (isNaN(number)) { + throw new Error("[ERROR] 보너스 번호는 숫자여야 합니다."); + } + + if (number < 1 || number > 45) { + throw new Error("[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다."); + } + }, +}; + +export default Validator; diff --git a/src/view/InputView.js b/src/view/InputView.js new file mode 100644 index 000000000..87239581d --- /dev/null +++ b/src/view/InputView.js @@ -0,0 +1,39 @@ +import { Console } from "@woowacourse/mission-utils"; +import Validator from "../util/Validator.js"; + +const InputView = { + async readPurchaseAmount() { + const purchaseAmount = await Console.readLineAsync( + "구입금액을 입력해 주세요.\n" + ); + + Validator.validatePurchaseAmount(purchaseAmount); + + return Number(purchaseAmount); + }, + + async readWinningNumbers() { + const inputNumbers = await Console.readLineAsync( + "당첨 번호를 입력해 주세요.\n" + ); + + const numbers = inputNumbers.split(","); + const winningNumbers = numbers.map((num) => Number(num.trim())); + + Validator.validateWinningNumbers(winningNumbers); + + return winningNumbers; + }, + + async readBonusNumber() { + const bonusNumber = await Console.readLineAsync( + "보너스 번호를 입력해 주세요.\n" + ); + + Validator.validateBonusNumber(bonusNumber); + + return Number(bonusNumber); + }, +}; + +export default InputView; diff --git a/src/view/OutputView.js b/src/view/OutputView.js new file mode 100644 index 000000000..c0042bd64 --- /dev/null +++ b/src/view/OutputView.js @@ -0,0 +1,56 @@ +import { Console } from "@woowacourse/mission-utils"; +import { PRIZES } from "../model/Rank.js"; + +const OutputView = { + printLottoBundle(lottoBundle) { + this.printLineBreak(); + + Console.print(`${lottoBundle.size()}개를 구매했습니다.`); + + lottoBundle.forEach((lotto) => + Console.print(`[${lotto.getNumbers().join(", ")}]`) + ); + + this.printLineBreak(); + }, + + printResult(rankCounts) { + this.printLineBreak(); + + Console.print("당첨 통계"); + Console.print("---"); + + Object.entries(rankCounts) + .sort(([a], [b]) => b - a) + .forEach(([rank, count]) => { + const prize = PRIZES[rank].toLocaleString("ko-KR"); + switch (Number(rank)) { + case 5: + Console.print(`3개 일치 (${prize}원) - ${count}개`); + break; + case 4: + Console.print(`4개 일치 (${prize}원) - ${count}개`); + break; + case 3: + Console.print(`5개 일치 (${prize}원) - ${count}개`); + break; + case 2: + Console.print(`5개 일치, 보너스 볼 일치 (${prize}원) - ${count}개`); + break; + case 1: + Console.print(`6개 일치 (${prize}원) - ${count}개`); + break; + } + }); + }, + + printProfitRate(profitRate) { + Console.print(`총 수익률은 ${profitRate}%입니다.`); + }, + + printLineBreak() { + Console.print(""); + }, +}; + +export default OutputView;