diff --git a/README.md b/README.md index b168a180..d3541424 100644 --- a/README.md +++ b/README.md @@ -1 +1,108 @@ # javascript-planetlotto-precourse + +## 요구사항 분석 + +### 1. 구입 금액 입력 + +- "구입금액을 입력해 주세요." 라는 문구와 함께 입력받는다. + +#### 입력 요구사항 + +- 구입 금액은 500원 단위의 정수만 가능하다. +- 올바르지 않은 입력을 한 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시키고 해당 메시지를 출력한 다음 다시 입력 받는다. + +### 2. 구매 내역 출력 + +- "${개수}개를 구매했습니다." 라는 문구와 함께 구입한 로또의 개수와 번호를 출력한다. +- 로또의 가격은 1개당 500원이며, 구매한 로또 개수는 구입금액 / 500 이다. +- 로또 번호는 1~30까지의 중복되지 않은 정수이다. +- 로또 번호는 오름차순으로 정렬해 보여준다. + +### 3. 당첨 번호 입력 + +- "당첨 번호를 입력해 주세요."라는 문구와 함께 당첨 번호를 입력받는다. + +#### 입력 요구사항 + +- 번호는 쉽표를 기준으로 구분한다. +- 번호는 1~30까지의 중복되지 않은 정수이다. +- 올바르지 않은 입력을 한 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시키고 해당 메시지를 출력한 다음 다시 입력 받는다. + +e.g. + +``` +[ERROR] 로또 번호는 1부터 30 사이의 숫자여야 합니다. +``` + +### 4. 보너스 번호 입력 + +- "보너스 번호 번호를 입력해 주세요." 문구와 함께 보너스 번호를 입력받는다. + +#### 입력 요구사항 + +- 번호는 1~30까지의 중복되지 않은 정수이다. +- 이전에 입력한 당첨 번호와 중복될 수 없다. +- 올바르지 않은 입력을 한 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시키고 해당 메시지를 출력한 다음 다시 입력 받는다. + +### 5. 당첨 통계 출력 + +- "당첨 통계\n---\n" 문구와 함께 당첨 통계를 출력한다. +- 당첨은 1등부터 5등까지 있으며, 당첨 기준과 금액은 아래와 같다. + - 1등: 5개 번호 일치 / 100,000,000원 + - 2등: 4개 번호 + 보너스 번호 일치 / 10,000,000원 + - 3등: 4개 번호 일치 / 1,500,000원 + - 4등: 3개 번호 일치 + 보너스 번호 일치 / 500,000원 + - 5등: 2개 번호 일치 + 보너스 번호 일치 / 5,000원 + +--- + +## 구현 사항 + +- [x] 1. 구입 금액 입력 + - [x] "구입금액을 입력해 주세요." 라는 문구와 함께 입력 받음 + - [x] 구입 금액은 500원 단위의 정수만 가능 + - [x] 올바르지 않은 입력을 한 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시키고 해당 메시지를 출력한 다음 다시 입력 받음 + +- [x] 2. 구매 내역 출력 + - [x] "${개수}개를 구매했습니다." 라는 문구와 함께 구입한 로또의 개수와 번호를 출력 + - [x] 로또의 가격은 1개당 500원이며, 구매한 로또 개수는 `구입금액 / 500` + - [x] 로또 번호는 1~30까지의 중복되지 않은 정수 + - [x] 로또 번호는 오름차순으로 정렬 + +- [x] 3. 당첨 번호 입력 + - [x] "당첨 번호를 입력해 주세요."라는 문구와 함께 당첨 번호를 입력받는다. + - [x] 번호는 쉽표를 기준으로 구분 + - [x] 번호는 1~30까지의 중복되지 않은 정수 + - [x] 올바르지 않은 입력을 한 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시키고 해당 메시지를 출력한 다음 다시 입력 받음 + +- [x] 4. 보너스 번호 입력 + - [x] "보너스 번호 번호를 입력해 주세요." 문구와 함께 보너스 번호를 입력받음 + - [x] 번호는 1~30까지의 중복되지 않은 정수 + - [x] 입력했던 당첨 번호와 중복되지 않음 + - [x] 올바르지 않은 입력을 한 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시키고 해당 메시지를 출력한 다음 다시 입력 받음 + +- [x] 5. 당첨 통계 출력 + - [x] "당첨 통계\n---\n" 문구와 함께 당첨 통계를 출력 + - [x] 당첨 통계 게산 + - [x]1등: 5개 번호 일치 + - [x]2등: 4개 번호 + 보너스 번호 일치 + - [x]3등: 4개 번호 일치 + - [x]4등: 3개 번호 일치 + 보너스 번호 일치 + - [x]5등: 2개 번호 일치 + 보너스 번호 일치 + +--- + +## 리팩토링 구현 사항 + +### 최소 조건 + +- [x] 모든 함수는 15줄을 넘지 않는다. +- [x] 모든 함수는 index level 2를 넘지 않는다. 단, class, try...cach 문의 들여쓰기는 제외한다. +- [x] 사용하지 않는 코드는 지운다. + +### 추가 조건 + +- [x] (테스트 코드를 포함하여) 코드의 반복을 최대한 줄인다. +- [ ] 객체의 상태 접근과 관련한 리팩터링을 한다. + - [ ] 클래스는 단순히 값을 반환하는 메서드를 가지는 것 보다는 그 기능을 하도록 구현한다. + - [ ] 어떤 값을 감춰야 할지 고민한다. diff --git a/__tests__/LottoDeviceTest.js b/__tests__/LottoDeviceTest.js new file mode 100644 index 00000000..37ff06d0 --- /dev/null +++ b/__tests__/LottoDeviceTest.js @@ -0,0 +1,26 @@ +import Lotto from "../src/Lotto.js"; +import LottoDevice from "../src/LottoDevice.js"; + +describe("LottoDeviceTest", () => { + let lottoDevice; + + beforeEach(() => { + lottoDevice = new LottoDevice(); + }); + + describe("getCanIssueAmount", () => { + it("입력받은 가격을 통해 구매 가능한 로또 개수를 반환한다.", () => { + const amount = 1000; + const result = LottoDevice.getCanIssueAmount(amount); + expect(result).toBe(2); + }); + }); + + describe("static calcRank", () => { + it("입력받은 로또 객체와 번호, 보너스 번호를 통하여 랭크를 반환한다.", () => { + const lotto = new Lotto([1, 2, 3, 4, 5]); + const result = LottoDevice.calcRank(lotto, [1, 2, 3, 4, 5], 7); + expect(result).toBe(1); + }); + }); +}); diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js new file mode 100644 index 00000000..57f691c1 --- /dev/null +++ b/__tests__/LottoTest.js @@ -0,0 +1,30 @@ +import Lotto from "../src/Lotto.js"; + +describe("LottoTest", () => { + it("로또의 가격은 500원이다.", () => { + const cost = Lotto.cost; + expect(cost).toBe(500); + }); + + describe("생성자에 수를 입력하여 로또를 발행한다.", () => { + it("6자리의 중복되지 않은 수를 입력해야한다.", () => { + const numbers = [1, 2, 3, 4, 5, 5]; + expect(() => new Lotto(numbers)).toThrow(); + }); + it("수의 범위는 1~30이다. 범위를 넘으면 에러를 던진다.", () => { + expect(() => new Lotto([-1, 2, 3, 4, 5])).toThrow(); + expect(() => new Lotto([1, 2, 3, 4, 31])).toThrow(); + }); + it("5자리 수를 입력받이 않으면 에러를 던진다.", () => { + const numbers = [1, 2, 3, 4, 5, 6]; + expect(() => new Lotto(numbers)).toThrow(); + }); + }); + describe("getLottoNumbers", () => { + it("발행한 로또의 번호를 가져온다.", () => { + const numbers = [1, 3, 4, 5, 6]; + const lotto = new Lotto(numbers); + expect(lotto.getLottoNumbers()).toEqual([1, 3, 4, 5, 6]); + }); + }); +}); diff --git a/__tests__/ValidatorTest.js b/__tests__/ValidatorTest.js new file mode 100644 index 00000000..543ace9f --- /dev/null +++ b/__tests__/ValidatorTest.js @@ -0,0 +1,46 @@ +import Validator from "../src/Validator.js"; + +describe("ValidatorTest", () => { + describe("checkIsValidAmount", () => { + describe("올바른 입력을 받으면 true를 반환한다. ", () => { + it("구매 금액은 500원 단위의 양의 정수여야한다.", () => { + const testcase = 500; + const result = Validator.checkIsValidAmount(testcase); + expect(result).toBeTruthy(); + }); + }); + describe("올바르지 않은 입력을 받으면 에러를 던진다.", () => { + it.each([123, -500])("%s 를 입력받으면 에러를 던진다.", (testcase) => { + expect(() => Validator.checkIsValidAmount(testcase)).toThrow(); + }); + }); + }); + describe("checkWinningNumber", () => { + describe("올바른 입력을 받으면 true를 반환한다. ", () => { + it("당첨 번호는 1~30의 양의 정수 5개이다.", () => { + const testcase = [1, 2, 3, 4, 5]; + const result = Validator.checkWinningNumber(testcase); + expect(result).toBeTruthy(); + }); + }); + // TODO: 추후 작성하기 + // describe("올바르지 않은 입력을 받으면 에러를 던진다.", () => { + // it.each([123, -500])("%s 를 입력받으면 에러를 던진다.", (testcase) => { + // expect(() => Validator.checkWinningNumber(testcase)).toThrow(); + // }); + // }); + }); + + describe("checkBonusNumber", () => { + describe("올바른 입력을 받으면 true를 반환한다.", () => { + it("예외 번호를 제외한 1 ~ 30의 양의 정수이다.", () => { + expect(Validator.checkBonusNumber(1, [7, 8, 9, 11])).toBeTruthy(); + }); + }); + describe("올바르지 않은 입력을 받은 경우 에러를 던진다.", () => { + it("예외 번호에 포함된 번호 입력시 에러 발생", () => { + expect(() => Validator.checkBonusNumber(1, [1, 8, 9, 11])).toThrow(); + }); + }); + }); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5..8dcabd88 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,62 @@ +import LottoDevice from "./LottoDevice.js"; +import { handleInputError } from "./utils.js"; +import Validator from "./Validator.js"; +import { InputView, OutputView } from "./view.js"; + class App { - async run() {} + async getAmount() { + const amount = await handleInputError({ + input: InputView.askAmount, + validator: Validator.checkIsValidAmount, + }); + + return amount; + } + + printPurchase(amount) { + const lottoDevice = new LottoDevice(); + + lottoDevice.issueLottos(amount); + const lottos = lottoDevice.getLottos(); + OutputView.printPurchasedLottos(lottos); + + return lottoDevice; + } + + async getWinningNumber() { + const winningNumbers = await handleInputError({ + input: InputView.askWinningLotto, + validator: Validator.checkWinningNumber, + }); + + return winningNumbers; + } + + async getBonusNumber(winningNumbers) { + const bonusNumber = await handleInputError({ + input: InputView.askBonusNumber, + validator: (input) => Validator.checkBonusNumber(input, [...winningNumbers]), + }); + + return bonusNumber; + } + + printResult(lottoDevice, winningNumbers, bonusNumber) { + const lottoServiceResult = lottoDevice.getRankResult(winningNumbers, bonusNumber); + OutputView.printResult(lottoServiceResult); + } + + async run() { + const amount = await this.getAmount(); + + const lottoDevice = this.printPurchase(amount); + + const winningNumbers = await this.getWinningNumber(); + + const bonusNumber = await this.getBonusNumber(winningNumbers); + + this.printResult(lottoDevice, winningNumbers, bonusNumber); + } } export default App; diff --git a/src/Lotto.js b/src/Lotto.js new file mode 100644 index 00000000..a7d88ecb --- /dev/null +++ b/src/Lotto.js @@ -0,0 +1,30 @@ +import ERROR_MESSAGE from "./constants/errorMessages.js"; + +class Lotto { + #numbers; + + static cost = 500; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = numbers; + } + + #validate(numbers) { + if (numbers.length !== 5) throw new Error(ERROR_MESSAGE.LOTTO.LOTTO_NUMBER); + + const set = new Set(); + numbers.forEach((n) => { + if (n > 30 || n < 1) throw new Error(ERROR_MESSAGE.LOTTO.LOTTO_RANGE); + set.add(n); + }); + if (set.size !== numbers.length) throw new Error(ERROR_MESSAGE.LOTTO.LOTTO_DUPLICATE); + } + + // TODO: 단순 가져오기보다는 일을 할 수 있도록 리팩토링 필요 + getLottoNumbers() { + return this.#numbers; + } +} + +export default Lotto; diff --git a/src/LottoDevice.js b/src/LottoDevice.js new file mode 100644 index 00000000..4f9fdbb7 --- /dev/null +++ b/src/LottoDevice.js @@ -0,0 +1,71 @@ +import { Random } from "@woowacourse/mission-utils"; +import Lotto from "./Lotto.js"; + +const DEFAULT_RANKS = [ + [1, 0], + [2, 0], + [3, 0], + [4, 0], + [5, 0], + [0, 0], +]; + +class LottoDevice { + #lottos; + + constructor() { + this.#lottos = []; + } + + issueLottos(amount) { + const issuedCount = LottoDevice.getCanIssueAmount(amount); + while (this.#lottos.length !== issuedCount) { + const numbers = LottoDevice.getRandomLottoNumbers(); + const lotto = new Lotto(numbers); + this.#lottos.push(lotto); + } + } + + getLottos() { + return this.#lottos.map((lotto) => lotto.getLottoNumbers()); + } + + static getCanIssueAmount(amount) { + return amount / Lotto.cost; + } + + static getRandomLottoNumbers() { + return Random.pickUniqueNumbersInRange(1, 30, 5).sort((a, b) => a - b); + } + + getRankResult(winningNumbers, bonusNumber) { + const ranks = new Map(DEFAULT_RANKS); + + this.#lottos.forEach((lotto) => { + const rank = LottoDevice.calcRank(lotto, winningNumbers, bonusNumber); + const prev = ranks.get(rank); + ranks.set(rank, prev + 1); + }); + + return ranks; + } + + static calcRank(lotto, numbers, bonusNumber) { + let correctCount = 0; + const lottoNumbers = lotto.getLottoNumbers(); + + numbers.forEach((n) => { + if (lottoNumbers.includes(n)) correctCount += 1; + }); + const isBonusNumCorrect = lottoNumbers.includes(bonusNumber); + + if (correctCount === 5) return 1; + if (correctCount === 4 && isBonusNumCorrect) return 2; + if (correctCount === 4) return 3; + if (correctCount === 3 && isBonusNumCorrect) return 4; + if (correctCount === 2 && isBonusNumCorrect) return 5; + return 0; + } +} + +export default LottoDevice; diff --git a/src/Validator.js b/src/Validator.js new file mode 100644 index 00000000..57474ee7 --- /dev/null +++ b/src/Validator.js @@ -0,0 +1,46 @@ +import ERROR_MESSAGE from "./constants/errorMessages.js"; + +const Validator = { + WINNING_NUMBERS_LENGTH: 5, + checkIsValidRange(input, errorMessage) { + if (input > 30 || input < 1) throw new Error(errorMessage); + }, + + checkIsNumber(input, errorMessage) { + if (typeof input !== "number") throw new Error(errorMessage); + }, + + checkIsValidAmount(amount) { + Validator.checkIsNumber(amount, ERROR_MESSAGE.VALIDATOR.AMOUNT_ERROR_MESSAGE); + if (amount % 500 !== 0) throw new Error(ERROR_MESSAGE.VALIDATOR.AMOUNT_ERROR_MESSAGE); + if (amount < 0) throw new Error(ERROR_MESSAGE.VALIDATOR.AMOUNT_ERROR_MESSAGE); + + return true; + }, + + checkWinningNumber(winningNumbers) { + if (winningNumbers.length !== Validator.WINNING_NUMBERS_LENGTH) + throw new Error(ERROR_MESSAGE.VALIDATOR.WINNING_NUMBER_ERROR_MESSAGE); + const set = new Set(); + winningNumbers.forEach((n) => { + if (Number.isNaN(n)) throw new Error(ERROR_MESSAGE.VALIDATOR.WINNING_NUMBER_ERROR_MESSAGE); + Validator.checkIsValidRange(n, ERROR_MESSAGE.VALIDATOR.WINNING_NUMBER_ERROR_MESSAGE); + set.add(n); + }); + + if (set.size !== 5) throw new Error(ERROR_MESSAGE.VALIDATOR.WINNING_NUMBER_ERROR_MESSAGE); + + return true; + }, + + checkBonusNumber(input, excepts) { + Validator.checkIsNumber(input, ERROR_MESSAGE.VALIDATOR.BONUS_NUMBER_ERROR_MESSAGE); + Validator.checkIsValidRange(input, ERROR_MESSAGE.VALIDATOR.BONUS_NUMBER_ERROR_MESSAGE); + if (excepts.includes(input)) + throw new Error(ERROR_MESSAGE.VALIDATOR.BONUS_NUMBER_ERROR_MESSAGE); + + return true; + }, +}; + +export default Validator; diff --git a/src/constants/errorMessages.js b/src/constants/errorMessages.js new file mode 100644 index 00000000..2908a442 --- /dev/null +++ b/src/constants/errorMessages.js @@ -0,0 +1,15 @@ +const ERROR_MESSAGE = { + VALIDATOR: { + AMOUNT_ERROR_MESSAGE: "구매 금액은 500원 단위로 입력해주세요.", + WINNING_NUMBER_ERROR_MESSAGE: "올바른 당첨 번호가 아닙니다.", + BONUS_NUMBER_ERROR_MESSAGE: "올바른 보너스 번호가 아닙니다.", + }, + + LOTTO: { + LOTTO_NUMBER: "로또 번호는 5개여야 합니다.", + LOTTO_RANGE: "로또 번호의 범위는 1 ~ 30까지 입니다.", + LOTTO_DUPLICATE: "로또 번호는 중복될 수 없습니다.", + }, +}; + +export default ERROR_MESSAGE; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 00000000..89eb3fde --- /dev/null +++ b/src/utils.js @@ -0,0 +1,13 @@ +import { OutputView } from "./view.js"; + +export async function handleInputError({ input, validator }) { + while (true) { + try { + const inputValue = await input(); + validator(inputValue); + return inputValue; + } catch (e) { + OutputView.printErrorMessage(e.message); + } + } +}