diff --git a/README.md b/README.md index 15bb106b5..ee8791deb 100644 --- a/README.md +++ b/README.md @@ -1 +1,48 @@ # javascript-lotto-precourse + +## 기능 목록 + +1. 입력 기능(InputView) + +- 구입금액 입력받기 + - 숫자 형식 검증 + - 양수 검증 + - 1000원 단위 검증 +- 당첨 번호 입력받기 + - 쉼표로 구분된 6개 숫자 입력 + - 1~45범위 검증 + - 중복 번호 검증 +- 보너스 번호 입력받기 + - 숫자 형식 검증 + - 1~45범위 검증 + +2. 로또 생성기능 (LottoMake, Lotto) + +- 로또 번호 자동 생성 + - 1~45 범위에서 중복 없이 6개 숫자 추출 + - 오름차순 정렬 +- 로또 객체 생성 및 검증 + - 로또 번호 개수 검증 + - 중복 번호 검증 + - 번호 범위 검증 +- 구입 금액에 따른 로또 발행 개수 계산 + - 1,000원당 1개 발행 + +3. 당첨 확인 기능 (WinningResult) + +- 각 로또의 당첨 여부 확인 + - 당첨 번호와 일치하는 개수 계산 + - 보너스 번호 포함 여부 확인 +- 당첨 등수 판정 +- 당첨 통계 집계 +- 총 수익금 계산 +- 수익률 계산 + +4. 출력 기능 (OutputView) + +- 구매한 로또 개수 출력 +- 발행된 로또 번호 출력 +- 당첨 통계 출력 + - 각 등수별 당첨 개수 + - 각 등수별 당첨 금액 +- 총 수익률 출력 diff --git a/__tests__/InputView.test.js b/__tests__/InputView.test.js new file mode 100644 index 000000000..9837e746c --- /dev/null +++ b/__tests__/InputView.test.js @@ -0,0 +1,48 @@ +import { Console } from "@woowacourse/mission-utils"; +import InputView from "../src/InputView"; + +jest.mock("@woowacourse/mission-utils", () => ({ + Console: { + readLineAsync: jest.fn(), + print: jest.fn(), + }, +})); + +describe("InputView 클래스 테스트", () => { + let inputView; + beforeEach(() => { + inputView = new InputView(); + jest.clearAllMocks(); + }); + describe("purchaseAmountInput", () => { + test("양수가 아니면 에러가 발생한다.", async () => { + Console.readLineAsync.mockResolvedValue("-1000"); + await expect(inputView.purchaseAmountInput()).rejects.toThrow("[ERROR] "); + }); + + test("숫자가 아니면 예외가 발생한다", async () => { + Console.readLineAsync.mockResolvedValue("abc"); + await expect(inputView.purchaseAmountInput()).rejects.toThrow("[ERROR]"); + }); + }); + + describe("winningNumbersInput", () => { + test("정상적인 당첨 번호 입력 시 배열을 반환한다", async () => { + Console.readLineAsync.mockResolvedValue("1,2,3,4,5,6"); + const numbers = await inputView.winningNumbersInput(); + expect(numbers).toEqual([1, 2, 3, 4, 5, 6]); + }); + test("6개가 아니면 예외가 발생한다", async () => { + Console.readLineAsync.mockResolvedValue("1,2,3,4,5"); + await expect(inputView.winningNumbersInput()).rejects.toThrow( + "[ERROR] 당첨 번호는 6개여야 합니다." + ); + }); + test("중복된 번호가 있으면 예외가 발생한다", async () => { + Console.readLineAsync.mockResolvedValue("1,2,3,4,5,5"); + await expect(inputView.winningNumbersInput()).rejects.toThrow( + "[ERROR] 당첨 번호는 중복될 수 없습니다." + ); + }); + }); +}); diff --git a/__tests__/LottoMaker.test.js b/__tests__/LottoMaker.test.js new file mode 100644 index 000000000..fec556ed9 --- /dev/null +++ b/__tests__/LottoMaker.test.js @@ -0,0 +1,30 @@ +import LottoMaker from "../src/LottoMaker"; + +describe("LottoMaker 클래스 테스트", () => { + test("생성된 로또 번호는 1~45 범위 내의 숫자다", () => { + const lotto = LottoMaker.createLottoNumbers(); + const numbers = lotto.getNumbers(); + + numbers.forEach((num) => { + expect(num).toBeGreaterThanOrEqual(1); + expect(num).toBeLessThanOrEqual(45); + }); + }); + + test("생성된 로또 번호는 중복이 없다", () => { + const lotto = LottoMaker.createLottoNumbers(); + const numbers = lotto.getNumbers(); + const uniqueNumbers = new Set(numbers); + + expect(uniqueNumbers.size).toBe(6); + }); + + test("생성된 로또 번호는 오름차순으로 정렬되어 있다", () => { + const lotto = LottoMaker.createLottoNumbers(); + const numbers = lotto.getNumbers(); + + for (let i = 0; i < numbers.length - 1; i++) { + expect(numbers[i]).toBeLessThan(numbers[i + 1]); + } + }); +}); diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js index 409aaf69b..2aa1fe496 100644 --- a/__tests__/LottoTest.js +++ b/__tests__/LottoTest.js @@ -7,6 +7,12 @@ describe("로또 클래스 테스트", () => { }).toThrow("[ERROR]"); }); + test("로또 번호가 6개 보다 작으면 예외가 발생한다", () => { + expect(() => { + new Lotto([1, 2, 3]); + }).toThrow("[ERROR]"); + }); + // TODO: 테스트가 통과하도록 프로덕션 코드 구현 test("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.", () => { expect(() => { diff --git a/__tests__/WinningResult.test.js b/__tests__/WinningResult.test.js new file mode 100644 index 000000000..8e6b781c6 --- /dev/null +++ b/__tests__/WinningResult.test.js @@ -0,0 +1,29 @@ +import Lotto from "../src/Lotto"; +import WinningResult from "../src/WinningResult"; + +describe("Winning Result 클래스 테스트", () => { + test("3개 일치 시 통계가 정확히 동작한다.", () => { + const result = new WinningResult(); + const lotto = new Lotto([1, 2, 3, 10, 11, 12]); + const winningNumbers = [1, 2, 3, 4, 5, 6]; + const bonusNumber = 7; + + result.checkWinning(lotto, winningNumbers, bonusNumber); + const statistics = result.getStatistics(); + expect(statistics["3개 일치"]).toBe(1); + expect(statistics["4개 일치"]).toBe(0); + }); + + test("총 상금을 정확히 계산한다.", () => { + const result = new WinningResult(); + const lotto1 = new Lotto([1, 2, 3, 10, 11, 12]); + const lotto2 = new Lotto([1, 2, 3, 4, 11, 12]); + const winningNumbers = [1, 2, 3, 4, 5, 6]; + const bonusNumber = 7; + result.checkWinning(lotto1, winningNumbers, bonusNumber); + result.checkWinning(lotto2, winningNumbers, bonusNumber); + + const totalPrize = result.calculateTotalPrize(); + expect(totalPrize).toBe(5000 + 50000); + }); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5d..ddc2e2a2c 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,44 @@ +import InputView from "./InputView.js"; +import OutputView from "./OutputView.js"; +import LottoMaker from "./LottoMaker.js"; +import WinningResult from "./WinningResult.js"; +import { Console } from "@woowacourse/mission-utils"; + class App { - async run() {} + async run() { + try { + const inputView = new InputView(); + + const purchaseAmount = await inputView.purchaseAmountInput(); + const count = Math.floor(purchaseAmount / 1000); + + OutputView.printPurchaseResult(count); + + const lottos = []; + for (let i = 0; i < count; i++) { + const lotto = LottoMaker.createLottoNumbers(); + lottos.push(lotto); + OutputView.printLottoNumbers(lotto); + } + + const winningNumbers = await inputView.winningNumbersInput(); + const bonusNumber = await inputView.bonusNumberInput(); + + const result = new WinningResult(); + for (const lotto of lottos) { + result.checkWinning(lotto, winningNumbers, bonusNumber); + } + + const totalPrize = result.calculateTotalPrize(); + OutputView.printStatistics( + result.getStatistics(), + totalPrize, + purchaseAmount + ); + } catch (error) { + Console.print(error.message); + } + } } export default App; diff --git a/src/InputView.js b/src/InputView.js new file mode 100644 index 000000000..6f8e556a7 --- /dev/null +++ b/src/InputView.js @@ -0,0 +1,53 @@ +import { Console } from "@woowacourse/mission-utils"; + +class InputView { + async purchaseAmountInput() { + const input = await Console.readLineAsync("구입 금액을 입력해 주세요.\n"); + const amount = Number(input); + + if (isNaN(amount) || amount <= 0) { + throw new Error("[ERROR] 구입 금액은 양수여야 합니다."); + } + + if (amount % 1000 !== 0) { + throw new Error("[ERROR] 구입 금액은 1,000원 단위여야 합니다."); + } + + return amount; + } + + async winningNumbersInput() { + const input = await Console.readLineAsync("\n당첨 번호를 입력해주세요.\n"); + const numbers = input.split(",").map((num) => Number(num.trim())); + + if (numbers.length !== 6) { + throw new Error("[ERROR] 당첨 번호는 6개여야 합니다."); + } + + if (numbers.some((num) => isNaN(num) || num < 1 || num > 45)) { + throw new Error("[ERROR] 당첨 번호는 1부터 45 사이의 숫자여야 합니다."); + } + + const uniqueNumbers = new Set(numbers); + if (uniqueNumbers.size !== 6) { + throw new Error("[ERROR] 당첨 번호는 중복될 수 없습니다."); + } + + return numbers; + } + + async bonusNumberInput() { + const input = await Console.readLineAsync( + "\n보너스 번호를 입력해주세요.\n" + ); + const number = Number(input.trim()); + + if (isNaN(number) || number < 1 || number > 45) { + throw new Error("[ERROR] 보너스 번호는 1부터 45 사이의 숫자여야 합니다."); + } + + return number; + } +} + +export default InputView; diff --git a/src/Lotto.js b/src/Lotto.js index cb0b1527e..efd584e95 100644 --- a/src/Lotto.js +++ b/src/Lotto.js @@ -10,9 +10,29 @@ class Lotto { if (numbers.length !== 6) { throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); } + + const uniqueNumbers = new Set(numbers); + if (uniqueNumbers.size !== 6) { + throw new Error("[ERROR] 로또 번호는 중복될 수 없습니다."); + } + + const isInvalidRange = numbers.some((num) => num < 1 || num > 45); + if (isInvalidRange) { + throw new Error("[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다."); + } } - // TODO: 추가 기능 구현 + getNumbers() { + return [...this.#numbers]; + } + + countMatches(winningNumbers) { + return this.#numbers.filter((num) => winningNumbers.includes(num)).length; + } + + hasBonus(bonusNumber) { + return this.#numbers.includes(bonusNumber); + } } export default Lotto; diff --git a/src/LottoMaker.js b/src/LottoMaker.js new file mode 100644 index 000000000..31aef4e24 --- /dev/null +++ b/src/LottoMaker.js @@ -0,0 +1,13 @@ +import { Random } from "@woowacourse/mission-utils"; +import Lotto from "./Lotto.js"; + +class LottoMaker { + static createLottoNumbers() { + const numbers = Random.pickUniqueNumbersInRange(1, 45, 6).sort( + (a, b) => a - b + ); + return new Lotto(numbers); + } +} + +export default LottoMaker; diff --git a/src/OutputView.js b/src/OutputView.js new file mode 100644 index 000000000..8270b2e02 --- /dev/null +++ b/src/OutputView.js @@ -0,0 +1,30 @@ +import { Console } from "@woowacourse/mission-utils"; +import WinningResult from "./WinningResult.js"; + +class OutputView { + static printPurchaseResult(count) { + Console.print(`\n${count}개를 구매했습니다.`); + } + + static printLottoNumbers(lotto) { + Console.print(`[${lotto.getNumbers().join(", ")}]`); + } + + static printStatistics(statistics, totalPrize, purchaseAmount) { + Console.print("\n당첨 통계"); + Console.print("---"); + + for (const [rank, prize] of Object.entries(WinningResult.PRIZE)) { + const label = prize.label; + const count = statistics[label]; + Console.print( + `${label} (${prize.prize.toLocaleString()}원) - ${count}개` + ); + } + + const rateOfReturn = ((totalPrize / purchaseAmount) * 100).toFixed(1); + Console.print(`총 수익률은 ${rateOfReturn}%입니다.`); + } +} + +export default OutputView; diff --git a/src/WinningResult.js b/src/WinningResult.js new file mode 100644 index 000000000..dbe82f4a7 --- /dev/null +++ b/src/WinningResult.js @@ -0,0 +1,56 @@ +class WinningResult { + static PRIZE = { + THREE: { match: 3, bonus: false, prize: 5000, label: "3개 일치" }, + FOUR: { match: 4, bonus: false, prize: 50000, label: "4개 일치" }, + FIVE: { match: 5, bonus: false, prize: 1500000, label: "5개 일치" }, + FIVE_BONUS: { + match: 5, + bonus: true, + prize: 30000000, + label: "5개 일치, 보너스 볼 일치", + }, + SIX: { match: 6, bonus: false, prize: 2000000000, label: "6개 일치" }, + }; + + constructor() { + this.statistics = { + "3개 일치": 0, + "4개 일치": 0, + "5개 일치": 0, + "5개 일치, 보너스 볼 일치": 0, + "6개 일치": 0, + }; + } + + checkWinning(lotto, winningNumbers, bonusNumber) { + const matchCount = lotto.countMatches(winningNumbers); + const hasBonus = lotto.hasBonus(bonusNumber); + + if (matchCount === 6) { + this.statistics["6개 일치"]++; + } else if (matchCount === 5 && hasBonus) { + this.statistics["5개 일치, 보너스 볼 일치"]++; + } else if (matchCount === 5) { + this.statistics["5개 일치"]++; + } else if (matchCount === 4) { + this.statistics["4개 일치"]++; + } else if (matchCount === 3) { + this.statistics["3개 일치"]++; + } + } + + calculateTotalPrize() { + let total = 0; + for (const [rank, prize] of Object.entries(WinningResult.PRIZE)) { + const label = prize.label; + total += this.statistics[label] * prize.prize; + } + return total; + } + + getStatistics() { + return { ...this.statistics }; + } +} + +export default WinningResult;