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;