diff --git a/README.md b/README.md index 15bb106b5..22e336b9d 100644 --- a/README.md +++ b/README.md @@ -1 +1,64 @@ # javascript-lotto-precourse + +## 기능 개요 + +1. 로또 구매 +2. 자동 로또 생성 +3. 로또 추첨 +4. 당첨 결과 및 수익률 출력 + +## 세부 기능 목록 + +1. 구매 금액 입력 + - 빈 문자열 예외 + - 1000원 단위가 아니면 예외 + +
+ +2. 구매 금액을 기준으로 로또 번호 생성 +3. 구매한 로또 개수와 생성된 로또 번호 출력 + +
+ +4. 당첨 번호 입력 + - 빈 문자열 예외 + - 번호가 쉼표로 구분되지 않으면 예외 + - 쉼표로 구분된 번호가 6개가 아니면 예외 + - 1-45 사이 숫자가 아니면 예외 + - 중복 숫자가 있으면 예외 +5. 보너스 번호 입력 + - 빈 문자열 예외 + - 1-45 사이 숫자가 아니면 예외 + - 당첨 번호 중 하나라도 중복되면 예외 + +
+ +6. 랜덤 생성된 로또 번호들마다 당첨 여부 확인 +7. 총 수익률 계산 +8. 당첨 통계(1-5등 개수, 총 수익률) 출력 + +## 폴더 구조 + +```bash +src +├─ constants +│ ├─ errorMessages.js +│ ├─ inputMessages.js +│ ├─ lotto.js +│ ├─ rank.js +│ └─ unit.js +├─ controller +│ └─ LottoController.js // 전체 로또 추첨 흐름 제어 +├─ domains +│ └─ Lotto.js // 단일 로또 상태 관리 +├─ models +│ ├─ DrawnNumbers.js // 당첨 번호 + 보너스 번호 상태 관리 +│ ├─ LottoDrawer.js // DrawnNumbers -> Lottos 상태 업데이트 +│ └─ Lottos.js // 사용자 로또 번호 상태 관리 +├─ utils +│ ├─ error.js +│ └─ Formatter.js // 출력문 형식 관리 +└─ view + ├─ InputView.js // 사용자 입력 처리 + └─ OutputView.js // 출력 처리 +``` diff --git a/__tests__/DrawnNumbersTest.js b/__tests__/DrawnNumbersTest.js new file mode 100644 index 000000000..aa69cd9bc --- /dev/null +++ b/__tests__/DrawnNumbersTest.js @@ -0,0 +1,101 @@ +import { + BONUS_NUMBER_ERROR_MESSAGES, + WINNING_NUMBER_ERROR_MESSAGES, +} from "../src/constants/errorMessages.js"; +import { RANK } from "../src/constants/rank.js"; +import Lotto from "../src/domains/Lotto.js"; +import DrawnNumbers from "../src/models/DrawnNumbers.js"; + +describe("DrawnNumbers 클래스", () => { + describe("생성자 테스트", () => { + test("당첨 번호 입력이 없으면 예외가 발생한다.", () => { + expect(() => { + new DrawnNumbers("", "1"); + }).toThrow(WINNING_NUMBER_ERROR_MESSAGES.NONEMPTY); + }); + + test("당첨 번호 입력이 공백이면 예외가 발생한다.", () => { + expect(() => { + new DrawnNumbers(" ", "1"); + }).toThrow(WINNING_NUMBER_ERROR_MESSAGES.NONEMPTY); + }); + + test("당첨 번호가 쉼표 외 구분자로 분리되어 있으면 예외가 발생한다.", () => { + expect(() => { + new DrawnNumbers("1. 2. 3. 4. 5. 6", "1"); + }).toThrow(WINNING_NUMBER_ERROR_MESSAGES.DELIMITER); + }); + + test("보너스 번호 입력이 없으면 예외가 발생한다.", () => { + expect(() => { + new DrawnNumbers("1, 2, 3, 4, 5, 6", ""); + }).toThrow(BONUS_NUMBER_ERROR_MESSAGES.NONEMPTY); + }); + + test("보너스 번호 입력이 공백이면 예외가 발생한다.", () => { + expect(() => { + new DrawnNumbers("1, 2, 3, 4, 5, 6", " "); + }).toThrow(BONUS_NUMBER_ERROR_MESSAGES.NONEMPTY); + }); + + test("보너스 번호가 1보다 작으면 예외가 발생한다.", () => { + expect(() => { + new DrawnNumbers("1, 2, 3, 4, 5, 6", "0"); + }).toThrow(BONUS_NUMBER_ERROR_MESSAGES.MIN_VALUE); + }); + + test("보너스 번호가 45보다 크면 예외가 발생한다.", () => { + expect(() => { + new DrawnNumbers("1, 2, 3, 4, 5, 6", "46"); + }).toThrow(BONUS_NUMBER_ERROR_MESSAGES.MAX_VALUE); + }); + + test("보너스 번호가 이미 당첨 번호에 포함되어 있으면 예외가 발생한다.", () => { + expect(() => { + new DrawnNumbers("1, 2, 3, 4, 5, 6", "1"); + }).toThrow(BONUS_NUMBER_ERROR_MESSAGES.UNIQUE); + }); + }); + + describe("calculateRank 메서드 테스트", () => { + test("당첨 번호와 로또 번호가 6개 모두 일치하는 경우 1등을 반환한다.", () => { + const lottoInstance = new Lotto([1, 2, 3, 4, 5, 6]); + const drawnNumbersInstance = new DrawnNumbers("1,2,3,4,5,6", "7"); + expect(drawnNumbersInstance.calculateRank(lottoInstance)).toBe( + RANK.FIRST + ); + }); + + test("당첨 번호와 로또 번호가 5개 일치하고, 로또 번호에 보너스 번호가 포함되어 있는 경우 2등을 반환한다.", () => { + const lottoInstance = new Lotto([1, 2, 3, 4, 5, 7]); + const drawnNumbersInstance = new DrawnNumbers("1,2,3,4,5,6", "7"); + expect(drawnNumbersInstance.calculateRank(lottoInstance)).toBe( + RANK.SECOND + ); + }); + + test("당첨 번호와 로또 번호가 5개 일치하는 경우 3등을 반환한다.", () => { + const lottoInstance = new Lotto([1, 2, 3, 4, 5, 8]); + const drawnNumbersInstance = new DrawnNumbers("1,2,3,4,5,6", "7"); + expect(drawnNumbersInstance.calculateRank(lottoInstance)).toBe( + RANK.THIRD + ); + }); + + test("당첨 번호와 로또 번호가 4개 일치하는 경우 4등을 반환한다.", () => { + const lottoInstance = new Lotto([1, 2, 3, 4, 7, 8]); + const drawnNumbersInstance = new DrawnNumbers("1,2,3,4,5,6", "7"); + expect(drawnNumbersInstance.calculateRank(lottoInstance)).toBe( + RANK.FOURTH + ); + }); + + test("당첨 번호와 로또 번호가 3개 일치하는 경우 5등을 반환한다.", () => { + const lottoInstance = new Lotto([1, 2, 3, 7, 8, 9]); + const drawnNumbersInstance = new DrawnNumbers("1,2,3,4,5,6", "7"); + expect(drawnNumbersInstance.calculateRank(lottoInstance)).toBe( + RANK.FIFTH + ); + }); + }); +}); diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js index 409aaf69b..70b5e500b 100644 --- a/__tests__/LottoTest.js +++ b/__tests__/LottoTest.js @@ -1,18 +1,62 @@ -import Lotto from "../src/Lotto"; +import { LOTTO_ERROR_MESSAGES } from "../src/constants/errorMessages.js"; +import Lotto from "../src/domains/Lotto.js"; -describe("로또 클래스 테스트", () => { - test("로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.", () => { - expect(() => { - new Lotto([1, 2, 3, 4, 5, 6, 7]); - }).toThrow("[ERROR]"); +describe("Lotto 클래스", () => { + describe("생성자 테스트", () => { + test("로또 번호의 개수가 6개 미만이면 예외가 발생한다.", () => { + expect(() => { + new Lotto([1, 2, 3, 4, 5]); + }).toThrow(LOTTO_ERROR_MESSAGES.LENGTH); + }); + + test("로또 번호의 개수가 6개를 넘어가면 예외가 발생한다.", () => { + expect(() => { + new Lotto([1, 2, 3, 4, 5, 6, 7]); + }).toThrow(LOTTO_ERROR_MESSAGES.LENGTH); + }); + + test("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.", () => { + expect(() => { + new Lotto([1, 2, 3, 4, 5, 5]); + }).toThrow(LOTTO_ERROR_MESSAGES.UNIQUE); + }); + + test("로또 번호에 1보다 작은 숫자가 있으면 예외가 발생한다.", () => { + expect(() => { + new Lotto([0, 2, 3, 4, 5, 6]); + }).toThrow(LOTTO_ERROR_MESSAGES.MIN_VALUE); + }); + + test("로또 번호에 45보다 큰 숫자가 있으면 예외가 발생한다.", () => { + expect(() => { + new Lotto([1, 2, 3, 4, 5, 46]); + }).toThrow(LOTTO_ERROR_MESSAGES.MAX_VALUE); + }); }); - // TODO: 테스트가 통과하도록 프로덕션 코드 구현 - test("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.", () => { - expect(() => { - new Lotto([1, 2, 3, 4, 5, 5]); - }).toThrow("[ERROR]"); + describe("compare 메서드 테스트", () => { + test("두 로또 인스턴스 사이의 공통 원소 개수를 반환한다.", () => { + const lotto1 = new Lotto([1, 3, 5, 7, 9, 11]); + const lotto2 = new Lotto([1, 2, 3, 4, 5, 6]); + expect(lotto2.compare(lotto1)).toBe(3); + }); + + test("두 로또 인스턴스 사이에 공통 원소가 없으면 0을 반환한다.", () => { + const lotto1 = new Lotto([1, 3, 5, 7, 9, 11]); + const lotto2 = new Lotto([2, 4, 6, 8, 10, 12]); + expect(lotto2.compare(lotto1)).toBe(0); + }); }); - // TODO: 추가 기능 구현에 따른 테스트 코드 작성 + describe("includes 메서드 테스트", () => { + test("로또 인스턴스에 인자값이 포함되어 있으면 true를 반환한다.", () => { + const lotto = new Lotto([1, 2, 3, 4, 5, 6]); + expect(lotto.includes(1)).toBe(true); + }); + + test("로또 인스턴스에 인자값이 포함되어 있지 않으면 false를 반환한다.", () => { + const lotto = new Lotto([1, 2, 3, 4, 5, 6]); + expect(lotto.includes(7)).toBe(false); + }); + }); }); diff --git a/__tests__/LottosTest.js b/__tests__/LottosTest.js new file mode 100644 index 000000000..a8814701f --- /dev/null +++ b/__tests__/LottosTest.js @@ -0,0 +1,62 @@ +import { RANK, RANK_TO_PRIZE_MAP } from "../src/constants/rank.js"; +import { PURCHASE_UNIT } from "../src/constants/unit.js"; +import Lotto from "../src/domains/Lotto.js"; +import Lottos from "../src/models/Lottos.js"; + +const PURCHASE_COUNT = 3; + +describe("Lottos 클래스", () => { + describe("생성자 테스트", () => { + test("입력 개수만큼의 lottos 배열을 생성한다.", () => { + const lottosInstance = new Lottos(PURCHASE_COUNT); + const lottos = lottosInstance.getLottos(); + expect(lottos).toHaveLength(PURCHASE_COUNT); + }); + + test("lottos 배열은 Lotto 인스턴스로 구성된다.", () => { + const lottosInstance = new Lottos(PURCHASE_COUNT); + const lottos = lottosInstance.getLottos(); + lottos.forEach((lotto) => { + expect(lotto).toBeInstanceOf(Lotto); + }); + }); + }); + + describe("win 메서드 테스트", () => { + test("순위에 따라 등수 통계를 업데이트한다.", () => { + Object.values(RANK).forEach((rank) => { + const lottosInstance = new Lottos(PURCHASE_COUNT); + lottosInstance.win(rank); + const ranks = lottosInstance.getRanks(); + expect(ranks[rank]).toBe(1); + }); + }); + + test("순위에 따라 총 상금을 업데이트한다.", () => { + Object.entries(RANK_TO_PRIZE_MAP).forEach(([rank, prize]) => { + const lottosInstance = new Lottos(PURCHASE_COUNT); + lottosInstance.win(rank); + const totalPrize = lottosInstance._getTotalPrize(); + expect(totalPrize).toBe(prize); + }); + }); + }); + + describe("calculateTotalReturn 메서드 테스트", () => { + test("상금을 수익률 형태로 변환해 반환한다.", () => { + Object.entries(RANK_TO_PRIZE_MAP).forEach(([rank, prize]) => { + const lottosInstance = new Lottos(PURCHASE_COUNT); + lottosInstance.win(rank); + + const purchaseAmount = PURCHASE_COUNT * PURCHASE_UNIT; + const expectedTotalPrize = prize; + const expectedTotalReturn = ( + expectedTotalPrize / purchaseAmount + ).toFixed(1); + + const totalReturn = lottosInstance.calculateTotalReturn(); + expect(totalReturn).toBe(expectedTotalReturn); + }); + }); + }); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5d..68f48ef5a 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(); + lottoController.run(); + } } 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/constants/errorMessages.js b/src/constants/errorMessages.js new file mode 100644 index 000000000..43c10f661 --- /dev/null +++ b/src/constants/errorMessages.js @@ -0,0 +1,42 @@ +import { error } from "../utils/error.js"; +import { LOTTO_MAX_VALUE, LOTTO_SIZE } from "./lotto.js"; +import { PURCHASE_UNIT } from "./unit.js"; + +export const PURCHASE_ERROR_MESSAGES = { + NONEMPTY: error("구입 금액을 입력해 주세요."), + UNIT: error(`구입 금액은 ${PURCHASE_UNIT}원 단위로 입력해야 합니다.`), +}; + +export const LOTTO_ERROR_MESSAGES = { + UNIQUE: error("로또 번호는 중복될 수 없습니다."), + LENGTH: error(`로또 번호는 ${LOTTO_SIZE}개여야 합니다.`), + MIN_VALUE: error( + `로또 번호는 ${LOTTO_MAX_VALUE}에서 ${LOTTO_MAX_VALUE} 사이의 숫자여야 합니다.` + ), + MAX_VALUE: error( + `로또 번호는 ${LOTTO_MAX_VALUE}에서 ${LOTTO_MAX_VALUE} 사이의 숫자여야 합니다.` + ), +}; + +export const WINNING_NUMBER_ERROR_MESSAGES = { + NONEMPTY: error("당첨 번호를 입력해 주세요."), + DELIMITER: error("당첨 번호는 쉼표로 구분되어야 합니다."), +}; + +export const BONUS_NUMBER_ERROR_MESSAGES = { + NONEMPTY: error("보너스 번호를 입력해 주세요."), + MIN_VALUE: error( + `보너스 번호는 ${LOTTO_MAX_VALUE}에서 ${LOTTO_MAX_VALUE} 사이의 숫자여야 합니다.` + ), + MAX_VALUE: error( + `보너스 번호는 ${LOTTO_MAX_VALUE}에서 ${LOTTO_MAX_VALUE} 사이의 숫자여야 합니다.` + ), + UNIQUE: error("이미 당첨 번호에 포함된 번호입니다."), +}; + +export const ERROR_MESSAGES = { + PURCHASE: PURCHASE_ERROR_MESSAGES, + LOTTO: LOTTO_ERROR_MESSAGES, + WINNING_NUMBER: WINNING_NUMBER_ERROR_MESSAGES, + BONUS_NUMBER: BONUS_NUMBER_ERROR_MESSAGES, +}; diff --git a/src/constants/inputMessages.js b/src/constants/inputMessages.js new file mode 100644 index 000000000..76acccc9b --- /dev/null +++ b/src/constants/inputMessages.js @@ -0,0 +1,5 @@ +export const INPUT_MESSAGES = { + PURCHASE_AMOUNT: "구입금액을 입력해 주세요.", + WINNING_NUMBER: "당첨 번호를 입력해 주세요.", + BONUS_NUMBER: "보너스 번호를 입력해 주세요.", +}; diff --git a/src/constants/lotto.js b/src/constants/lotto.js new file mode 100644 index 000000000..e2051631e --- /dev/null +++ b/src/constants/lotto.js @@ -0,0 +1,3 @@ +export const LOTTO_MIN_VALUE = 1; +export const LOTTO_MAX_VALUE = 45; +export const LOTTO_SIZE = 6; diff --git a/src/constants/rank.js b/src/constants/rank.js new file mode 100644 index 000000000..7239f6164 --- /dev/null +++ b/src/constants/rank.js @@ -0,0 +1,23 @@ +export const RANK = { + FIRST: "1st", + SECOND: "2nd", + THIRD: "3rd", + FOURTH: "4th", + FIFTH: "5th", +}; + +export const RANK_TO_PRIZE_MAP = { + [RANK.FIRST]: 2000000000, + [RANK.SECOND]: 30000000, + [RANK.THIRD]: 1500000, + [RANK.FOURTH]: 50000, + [RANK.FIFTH]: 5000, +}; + +export const RANK_TO_MATCH_STRING_MAP = { + [RANK.FIRST]: "6개 일치", + [RANK.SECOND]: "5개 일치, 보너스 볼 일치", + [RANK.THIRD]: "5개 일치", + [RANK.FOURTH]: "4개 일치", + [RANK.FIFTH]: "3개 일치", +}; diff --git a/src/constants/unit.js b/src/constants/unit.js new file mode 100644 index 000000000..4da292a5f --- /dev/null +++ b/src/constants/unit.js @@ -0,0 +1 @@ +export const PURCHASE_UNIT = 1000; diff --git a/src/controller/LottoController.js b/src/controller/LottoController.js new file mode 100644 index 000000000..1e65ce2b4 --- /dev/null +++ b/src/controller/LottoController.js @@ -0,0 +1,103 @@ +import { PURCHASE_ERROR_MESSAGES } from "../constants/errorMessages.js"; +import { INPUT_MESSAGES } from "../constants/inputMessages.js"; +import { PURCHASE_UNIT } from "../constants/unit.js"; +import LottoDrawer from "../models/LottoDrawer.js"; +import Lottos from "../models/Lottos.js"; +import Formatter from "../utils/Formatter.js"; +import InputView from "../view/InputView.js"; +import OutputView from "../view/OutputView.js"; + +class LottoController { + #inputView; + #outputView; + #formatter; + + constructor() { + this.#inputView = new InputView(); + this.#outputView = new OutputView(); + this.#formatter = new Formatter(); + } + + async run() { + const purchaseCount = await this.#readPurchaseCount(); + const lottos = new Lottos(purchaseCount); + this.#printInputResult(purchaseCount, lottos); + + const { winningNumber, bonusNumber } = await this.#readDrawnNumbers(); + const lottoDrawer = new LottoDrawer(winningNumber, bonusNumber); + lottoDrawer.run(lottos); + + this.#printResult(lottos); + } + + async #readPurchaseCount() { + const purchaseAmount = await this.#inputView.readLineAsync( + INPUT_MESSAGES.PURCHASE_AMOUNT + ); + this.#validatePurchaseAmount(purchaseAmount); + + const purchaseCount = purchaseAmount / PURCHASE_UNIT; + return purchaseCount; + } + + #validatePurchaseAmount(purchaseAmount) { + if (purchaseAmount === "") { + throw new Error(PURCHASE_ERROR_MESSAGES.NONEMPTY); + } + + if (purchaseAmount % PURCHASE_UNIT !== 0) { + throw new Error(PURCHASE_ERROR_MESSAGES.UNIT); + } + } + + async #readDrawnNumbers() { + const winningNumber = await this.#inputView.readLineAsync( + INPUT_MESSAGES.WINNING_NUMBER + ); + const bonusNumber = await this.#inputView.readLineAsync( + INPUT_MESSAGES.BONUS_NUMBER + ); + + return { winningNumber, bonusNumber }; + } + + #printInputResult(purchaseCount, lottos) { + this.#printPurchaseCount(purchaseCount); + this.#printLottos(lottos); + } + + #printPurchaseCount(purchaseCount) { + const formattedPurchaseCount = + this.#formatter.formatPurchaseCount(purchaseCount); + this.#outputView.print(formattedPurchaseCount); + } + + #printLottos(lottos) { + const lottoArray = lottos.getLottos(); + const formattedLottos = this.#formatter.formatLottos(lottoArray); + this.#outputView.print(formattedLottos); + } + + #printResult(lottos) { + const ranks = lottos.getRanks(); + const totalReturn = lottos.calculateTotalReturn(); + + this.#outputView.print(); + this.#outputView.print("당첨 통계"); + this.#outputView.print("---"); + this.#printRankResult(ranks); + this.#printTotalReturn(totalReturn); + } + + #printRankResult(ranks) { + const formattedRankResult = this.#formatter.formatRankResult(ranks); + this.#outputView.print(formattedRankResult); + } + + #printTotalReturn(totalReturn) { + const formattedTotalReturn = this.#formatter.formatTotalReturn(totalReturn); + this.#outputView.print(formattedTotalReturn); + } +} + +export default LottoController; diff --git a/src/domains/Lotto.js b/src/domains/Lotto.js new file mode 100644 index 000000000..ec474c475 --- /dev/null +++ b/src/domains/Lotto.js @@ -0,0 +1,47 @@ +import { LOTTO_ERROR_MESSAGES } from "../constants/errorMessages.js"; +import { + LOTTO_MAX_VALUE, + LOTTO_MIN_VALUE, + LOTTO_SIZE, +} from "../constants/lotto.js"; + +class Lotto { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = numbers; + } + + #validate(numbers) { + if (numbers.length !== LOTTO_SIZE) + throw new Error(LOTTO_ERROR_MESSAGES.LENGTH); + + const numberSet = new Set(numbers); + if (numbers.length > numberSet.size) + throw new Error(LOTTO_ERROR_MESSAGES.UNIQUE); + + if (numbers.some((num) => num < LOTTO_MIN_VALUE)) + throw new Error(LOTTO_ERROR_MESSAGES.MIN_VALUE); + + if (numbers.some((num) => num > LOTTO_MAX_VALUE)) + throw new Error(LOTTO_ERROR_MESSAGES.MAX_VALUE); + } + + compare(anotherLotto) { + return this.#numbers.reduce( + (total, num) => (anotherLotto.includes(num) ? total + 1 : total), + 0 + ); + } + + includes(num) { + return this.#numbers.includes(num); + } + + getNumbers() { + return this.#numbers; + } +} + +export default Lotto; diff --git a/src/models/DrawnNumbers.js b/src/models/DrawnNumbers.js new file mode 100644 index 000000000..fb889f22e --- /dev/null +++ b/src/models/DrawnNumbers.js @@ -0,0 +1,71 @@ +import { + BONUS_NUMBER_ERROR_MESSAGES, + WINNING_NUMBER_ERROR_MESSAGES, +} from "../constants/errorMessages.js"; +import { LOTTO_MAX_VALUE, LOTTO_MIN_VALUE } from "../constants/lotto.js"; +import { RANK } from "../constants/rank.js"; +import Lotto from "../domains/Lotto.js"; + +class DrawnNumbers { + #winningNumbers; + #bonusNumber; + + constructor(numbersString, bonusNumberString) { + this.#winningNumbers = this.#createWinningNumbers(numbersString); + this.#validateBonusNumber(bonusNumberString); + this.#bonusNumber = Number(bonusNumberString); + } + + calculateRank(lotto) { + const matchCount = lotto.compare(this.#winningNumbers); + const hasBonus = lotto.includes(this.#bonusNumber); + + if (matchCount === 6) return RANK.FIRST; + if (matchCount === 5 && hasBonus) return RANK.SECOND; + if (matchCount === 5) return RANK.THIRD; + if (matchCount === 4) return RANK.FOURTH; + if (matchCount === 3) return RANK.FIFTH; + return undefined; + } + + #createWinningNumbers(numbersString) { + this.#validateWinningNumbers(numbersString); + const winningNumberArray = numbersString.split(",").map(Number); + return new Lotto(winningNumberArray); + } + + #validateWinningNumbers(numbersString) { + const trimmedString = numbersString.trim(); + if (trimmedString === "") { + throw new Error(WINNING_NUMBER_ERROR_MESSAGES.NONEMPTY); + } + + const format = /^\s*\d+(\s*,\s*\d+)*\s*$/; + if (!format.test(trimmedString)) { + throw new Error(WINNING_NUMBER_ERROR_MESSAGES.DELIMITER); + } + } + + #validateBonusNumber(bonusNumberString) { + const trimmedString = bonusNumberString.trim(); + if (trimmedString === "") { + throw new Error(BONUS_NUMBER_ERROR_MESSAGES.NONEMPTY); + } + + const bonusNumber = Number(trimmedString); + + if (bonusNumber < LOTTO_MIN_VALUE) { + throw new Error(BONUS_NUMBER_ERROR_MESSAGES.MIN_VALUE); + } + + if (bonusNumber > LOTTO_MAX_VALUE) { + throw new Error(BONUS_NUMBER_ERROR_MESSAGES.MAX_VALUE); + } + + if (this.#winningNumbers.includes(bonusNumber)) { + throw new Error(BONUS_NUMBER_ERROR_MESSAGES.UNIQUE); + } + } +} + +export default DrawnNumbers; diff --git a/src/models/LottoDrawer.js b/src/models/LottoDrawer.js new file mode 100644 index 000000000..2629f4f5a --- /dev/null +++ b/src/models/LottoDrawer.js @@ -0,0 +1,19 @@ +import DrawnNumbers from "./DrawnNumbers.js"; + +class LottoDrawer { + #drawnNumbers; + + constructor(winningNumbers, bonusNumber) { + this.#drawnNumbers = new DrawnNumbers(winningNumbers, bonusNumber); + } + + run(lottos) { + const lottoArray = lottos.getLottos(); + lottoArray.forEach((lotto) => { + const rank = this.#drawnNumbers.calculateRank(lotto); + if (rank) lottos.win(rank); + }); + } +} + +export default LottoDrawer; diff --git a/src/models/Lottos.js b/src/models/Lottos.js new file mode 100644 index 000000000..c940abadb --- /dev/null +++ b/src/models/Lottos.js @@ -0,0 +1,62 @@ +import { Random } from "@woowacourse/mission-utils"; +import { RANK, RANK_TO_PRIZE_MAP } from "../constants/rank.js"; +import { PURCHASE_UNIT } from "../constants/unit.js"; +import { + LOTTO_MAX_VALUE, + LOTTO_MIN_VALUE, + LOTTO_SIZE, +} from "../constants/lotto.js"; +import Lotto from "../domains/Lotto.js"; + +class Lottos { + #lottos; + #ranks; + #totalPrize; + + constructor(purchaseCount) { + this.#lottos = Array.from({ length: purchaseCount }, () => + this.#createLotto() + ); + this.#ranks = this.#createRankCount(); + this.#totalPrize = 0; + } + + #createLotto() { + const start = LOTTO_MIN_VALUE; + const end = LOTTO_MAX_VALUE; + const size = LOTTO_SIZE; + const numbers = Random.pickUniqueNumbersInRange(start, end, size); + return new Lotto(numbers); + } + + #createRankCount() { + return Object.values(RANK).reduce((acc, cur) => ({ ...acc, [cur]: 0 }), {}); + } + + win(rank) { + this.#ranks[rank] += 1; + this.#totalPrize += RANK_TO_PRIZE_MAP[rank]; + } + + calculateTotalReturn() { + const purchaseCount = this.#lottos.length; + const purchaseAmount = purchaseCount * PURCHASE_UNIT; + + const totalReturn = (this.#totalPrize / purchaseAmount).toFixed(1); + return totalReturn; + } + + getLottos() { + return this.#lottos; + } + + getRanks() { + return this.#ranks; + } + + _getTotalPrize() { + return this.#totalPrize; + } +} + +export default Lottos; diff --git a/src/utils/Formatter.js b/src/utils/Formatter.js new file mode 100644 index 000000000..fd8c95edc --- /dev/null +++ b/src/utils/Formatter.js @@ -0,0 +1,38 @@ +import { + RANK_TO_MATCH_STRING_MAP, + RANK_TO_PRIZE_MAP, +} from "../constants/rank.js"; + +class Formatter { + formatLottoNumbers(numbers) { + return `[${numbers.join(", ")}]`; + } + + formatLottos(lottoArray) { + const numbersArray = lottoArray.map((lotto) => lotto.getNumbers()); + const formattedLottos = numbersArray.map((numbers) => + this.formatLottoNumbers(numbers) + ); + return formattedLottos.join("\n"); + } + + formatPurchaseCount(purchaseCount) { + return `${purchaseCount}개를 구매했습니다.`; + } + + formatRankResult(ranks) { + const formattedRankResult = Object.entries(RANK_TO_PRIZE_MAP).map( + ([rank, prize]) => + `${RANK_TO_MATCH_STRING_MAP[rank]} (${prize.toLocaleString()}원) - ${ + ranks[rank] + }개` + ); + return formattedRankResult.join("\n"); + } + + formatTotalReturn(totalReturn) { + return `총 수익률은 ${totalReturn}%입니다.`; + } +} + +export default Formatter; diff --git a/src/utils/error.js b/src/utils/error.js new file mode 100644 index 000000000..e09f6c3fd --- /dev/null +++ b/src/utils/error.js @@ -0,0 +1 @@ +export const error = (message) => `[ERROR] ${message}`; diff --git a/src/view/InputView.js b/src/view/InputView.js new file mode 100644 index 000000000..0ea587994 --- /dev/null +++ b/src/view/InputView.js @@ -0,0 +1,9 @@ +import { Console } from "@woowacourse/mission-utils"; + +class InputView { + async readLineAsync(question) { + return await Console.readLineAsync(`${question}\n`); + } +} + +export default InputView; diff --git a/src/view/OutputView.js b/src/view/OutputView.js new file mode 100644 index 000000000..67ecb63d5 --- /dev/null +++ b/src/view/OutputView.js @@ -0,0 +1,9 @@ +import { Console } from "@woowacourse/mission-utils"; + +class OutputView { + print(value = "") { + Console.print(value); + } +} + +export default OutputView;