diff --git a/README.md b/README.md index b168a180..87a3e2bb 100644 --- a/README.md +++ b/README.md @@ -1 +1,48 @@ # javascript-planetlotto-precourse + +## 전체 기능목록 +### 입력 +- [x] 로또 구입 금액을 입력 +- [x] 당첨 번호 입력 +- [x] 보너스 번호 입력 + +### 출력 +- [x] 발행한 로또 수량 및 번호를 출력 +- [x] 당첨 통계 출력 +- [x] 예외 발생시 에러 문구 출력 + +### 검증 +- [x] 구입 금액, 로또 번호, 보너스 번호가 숫자인지 검증(문제에서 주어진 기능) + +### 뷰는 이미 문제에 구현되어 있으므로 이를 활용해서 나머지를 구현해야함! 뷰에서 요구하는파라미터를 제공해야한다 + +## 실제 구현한 기능 목록 + +### 검증 +- [x] 구입 금액 양수 or 0인지 검증 +- [x] 당첨 번호가 1~30사이의 정수인지 검증 +- [x] 로또 번호가 5개인지 검증 +- [x] 보너스 번호가 1~30 사이의 정수인지 검증 +- [x] 로또 번호와 보너스 번호에 중복이 없는지 검증 + + +### 로또번호 +- [x] 로또 번호는 5개의 중복되지 않는 1~30사이의 정렬된 랜덤으로 중복없이 생성된 숫자를 가짐 +- [x] 로또 구입 금액에 따라 로또를 생성 +- [x] 당첨번호와 보너스 번호를 입력받아서 등수를 반환 +- [x] 모든 로또의 등수 목록(countsByRank)를 뷰에서 원하는 형식에 맞춰 반환 + +## 도전목록 (15:46 시작 종료시간 17:00) + +최종테스트에서 도전은 리팩토링과 확장성을 고려한 하드코딩된 값 제거에 초점을 두었습니다. +승리 조건을 상수로 관리하여 핵심 로직(도메인, 리포지토리, 서비스)에 손대지 않고도 상수파일 변경만으로 승리 조건을 바꿀 수 있게 했습니다. + +### 리팩토링 도전 (하드코딩 제거) +- [x] 에러 메시지 목록 파일로 분리 및 상수처럼 사용 +- [x] 승리 조건 상수화 하고 중복제거 및 확장성 향상(승리조건 변형 가능) +- [ ] 확장성을 고려하여 승리 조건을 컨트롤러에 주입하여 사용(보류, 필수아닌거같고 시간 오래걸리고 코드 지저분해짐) +- [x] 로또 가격 상수 분리 +- [x] 로또 숫자 범위 상수 분리 +- [x] 로또 개수 상수 분리 +- [x] 최소구매금액 상수 분리(기본 0) +- [x] 로또번호 정렬 조건 상수화 (기본 오름차순, 함수의 상수화) \ No newline at end of file diff --git a/__tests__/LottoRepositoryTest.js b/__tests__/LottoRepositoryTest.js new file mode 100644 index 00000000..3f9c5fff --- /dev/null +++ b/__tests__/LottoRepositoryTest.js @@ -0,0 +1,27 @@ +import LottoRepository from "../src/repository/LottoRepository"; +import Lotto from "../src/domain/Lotto.js"; + +describe("로또 리포지토리 테스트", () => { + test("발행한 로또 목록을 저장한다.", () => { + const repository = new LottoRepository(); + const lotto = new Lotto([1, 2, 3, 4, 5]); + + repository.save(lotto); + + const lottos = repository.findAllAsNumbers(); + expect(lottos).toHaveLength(1); + expect(lottos[0]).toBe(lotto.getNumbers()); + }); + + test("발행한 로또 개수를 조회한다.", () => { + const repository = new LottoRepository(); + const lotto1 = new Lotto([1, 2, 3, 4, 5]); + const lotto2 = new Lotto([7, 8, 9, 10, 11]); + + repository.save(lotto1); + repository.save(lotto2); + + expect(repository.findAllAsNumbers().length).toBe(2); + }); +}); + diff --git a/__tests__/LottoServiceTest.js b/__tests__/LottoServiceTest.js new file mode 100644 index 00000000..bd0dc82d --- /dev/null +++ b/__tests__/LottoServiceTest.js @@ -0,0 +1,19 @@ +import LottoService from "../src/service/LottoService.js"; +import LottoRepository from "../src/repository/LottoRepository.js"; + +describe("당첨 계산 서비스 테스트", () => { + test("구매한 금액에 따른 로또 개수가 생성됐는지 테스트", () => { + const lottoRepository = new LottoRepository(); + const service = new LottoService( + lottoRepository + ); + + service.purchase(1500); + + + const purchasedLottos = lottoRepository.findAllAsNumbers(); + + expect(purchasedLottos).toHaveLength(3); + }); + +}); \ No newline at end of file diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js new file mode 100644 index 00000000..3fc6a6f4 --- /dev/null +++ b/__tests__/LottoTest.js @@ -0,0 +1,29 @@ +import Lotto from "../src/domain/Lotto.js"; + +describe("로또 클래스 테스트", () => { + test("로또 번호가 내림차순으로 들어와도 오름차순으로 정렬된다.", () => { + const lotto = new Lotto([45, 44, 43, 42, 41]); + expect(lotto.getNumbers()).toEqual([41, 42, 43, 44, 45]); + }); + + test("로또 번호가 무작위 순서로 들어와도 오름차순으로 정렬된다.", () => { + const lotto = new Lotto([3, 1, 5, 2, 4]); + expect(lotto.getNumbers()).toEqual([1, 2, 3, 4, 5]); + }); + + test("등수 테스트 3등", () => { + const lotto = new Lotto([3, 1, 5, 2, 4]); + + const rank = lotto.calculateRanking([1,2,3,4,7], 8); + + expect(rank).toBe(3); + }); + + test("등수 테스트 2등", () => { + const lotto = new Lotto([3, 1, 5, 2, 4]); + + const rank = lotto.calculateRanking([1,2,3,4,7], 5); + + expect(rank).toBe(2); + }); +}); diff --git a/__tests__/LottoValidatorTest.js b/__tests__/LottoValidatorTest.js new file mode 100644 index 00000000..6ae2d168 --- /dev/null +++ b/__tests__/LottoValidatorTest.js @@ -0,0 +1,46 @@ +import LottoValidator from "../src/util/LottoValidator.js"; + +describe("검증 테스트", () => { + let lottoValidator; + + beforeEach(() => { + lottoValidator = new LottoValidator(); + }); + + test("구입금액이 음수일 경우 예외발생", () => { + expect(() => { + lottoValidator.validateAmount(-1000); + }).toThrow(); + }); + + test("로또번호 범위를 벗어날 경우 예외 발생", () => { + expect(() => { + lottoValidator.validateLottoNumbers([1,31,2,3,4]); + }).toThrow(); + }) + + test("로또번호 개수가 틀릴 경우 예외 발생", () => { + expect(() => { + lottoValidator.validateLottoNumbers([1,6,2,3,4,5]); + }).toThrow(); + }) + + test("보너스번호 범위를 벗어날 경우 예외 발생", () => { + expect(() => { + lottoValidator.validateBonusNumber(31,[1,2,3,4,5]); + }).toThrow(); + }) + + test("중복된 번호가 있을 경우 예외 발생", () => { + expect(() => { + lottoValidator.validateDuplicate([1,2,3,5,5]); + }).toThrow(); + }) + + test("보너스번호가 로또 목록에 있을 경우 예외 발생", () => { + expect(() => { + lottoValidator.validateBonusNumber(3,[1,2,3,4,5]); + }).toThrow(); + }) +}); + diff --git a/src/App.js b/src/App.js index 091aa0a5..b80ea2ec 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(); + await lottoController.run(); + } } export default App; diff --git a/src/config/ErrorMessage.js b/src/config/ErrorMessage.js new file mode 100644 index 00000000..2817172d --- /dev/null +++ b/src/config/ErrorMessage.js @@ -0,0 +1,12 @@ +import OptionalConstant from "./OptionalConstant.js"; + +class ErrorMessage{ + static POSITIVE = `구입 금액은 ${OptionalConstant.AMOUNT_CONDITION}이상이어야합니다.`; + static DUPLICATE = '중복된 번호가 있으면 안 됩니다.'; + static BONUS_DUPLICATE = '보너스 번호는 당첨번호와 중복되면 안 됩니다.'; + static COUNT = `로또 번호는 ${OptionalConstant.LOTTO_COUNT}개여야합니다.`; + static RANGE = + `번호는 ${OptionalConstant.LOTTO_NUMBER_RANGE.MIN}~${OptionalConstant.LOTTO_NUMBER_RANGE.MAX} 사이의 정수여야합니다.`; +} + +export default ErrorMessage; \ No newline at end of file diff --git a/src/config/OptionalConstant.js b/src/config/OptionalConstant.js new file mode 100644 index 00000000..e0f4ba4f --- /dev/null +++ b/src/config/OptionalConstant.js @@ -0,0 +1,25 @@ +class OptionalConstant{ + //등수별 맞춘 개수 + static WINNING_CONDITION ={ + 1: {WINNING_NUMBERS: 5, HAS_BONUS: false}, + 2: {WINNING_NUMBERS: 4, HAS_BONUS: true}, + 3: {WINNING_NUMBERS: 4, HAS_BONUS: false}, + 4: {WINNING_NUMBERS: 3, HAS_BONUS: true}, + 5: {WINNING_NUMBERS: 2, HAS_BONUS: true} + } + + static LOTTO_PRICE = 500; + + static LOTTO_NUMBER_RANGE = { + MIN : 1, + MAX : 30 + } + + static LOTTO_COUNT = 5; + + static AMOUNT_CONDITION = 0; + + static SORT_CONDITION = (a, b) => a - b; +} + +export default OptionalConstant \ No newline at end of file diff --git a/src/controller/LottoController.js b/src/controller/LottoController.js new file mode 100644 index 00000000..bf6278d2 --- /dev/null +++ b/src/controller/LottoController.js @@ -0,0 +1,94 @@ +import LottoService from "../service/LottoService.js"; +import {InputView, OutputView} from "../view.js"; +import LottoValidator from "../util/LottoValidator.js"; + +class LottoController { + #service; + #inputView; + #outputView; + #validator; + + /** + * @param service + * @param inputView + * @param outputView + * @param validator + */ + constructor(service = new LottoService(), inputView = InputView, + outputView = OutputView, validator = new LottoValidator()) { + this.#service = service; + this.#inputView = inputView; + this.#outputView = outputView; + this.#validator = validator; + } + + /** + * + * @returns {Promise} + */ + async run() { + await this.#tryAskAmountAndPurchaseLottos(); + const lottos = this.#service.getAllLottoNumbers(); + this.#outputView.printPurchasedLottos(lottos); + const countByRank = await this.#tryAskWinningResultAndGetCountByRank(); + this.#outputView.printResult(countByRank); + } + + /** + * + * @returns {Promise} + */ + async #tryAskAmountAndPurchaseLottos() { + try { + const amount = await this.#inputView.askAmount(); + this.#validator.validateAmount(amount); + this.#service.purchase(amount); + } catch (error) { + this.#outputView.printErrorMessage(error.message); + await this.#tryAskAmountAndPurchaseLottos(); + } + } + + /** + * + * @returns {Promise>} + */ + async #tryAskWinningResultAndGetCountByRank() { + const winningLotto = await this.#tryAskWinningLotto(); + const bonusNumber = await this.#tryAskBonusNumber(winningLotto); + return this.#service.getCountByRankAsMap(winningLotto, bonusNumber); + } + + /** + * + * @returns {Promise} + */ + async #tryAskWinningLotto() { + try { + const winningLotto = await this.#inputView.askWinningLotto(); + this.#validator.validateLottoNumbers(winningLotto); + return winningLotto; + } catch (error) { + this.#outputView.printErrorMessage(error.message); + await this.#tryAskWinningLotto(); + } + } + + /** + * + * @param winningLotto + * @returns {Promise} + */ + async #tryAskBonusNumber(winningLotto) { + try { + const bonusNumber = await this.#inputView.askBonusNumber(); + this.#validator.validateBonusNumber(bonusNumber, winningLotto); + return bonusNumber; + } catch (error) { + this.#outputView.printErrorMessage(error.message); + await this.#tryAskBonusNumber(winningLotto); + } + } +} + +export default LottoController; diff --git a/src/domain/Lotto.js b/src/domain/Lotto.js new file mode 100644 index 00000000..ba4fdb6b --- /dev/null +++ b/src/domain/Lotto.js @@ -0,0 +1,54 @@ +import OptionalConstant from "../config/OptionalConstant.js"; + +class Lotto { + #numbers; + + /** + * + * @param numbers + */ + constructor(numbers) { + this.#numbers = numbers.sort(OptionalConstant.SORT_CONDITION); + } + + /** + * + * @returns {*} + */ + getNumbers() { + return this.#numbers; + } + + /** + * + * @param numbers + * @param bonus + * @returns {number} + */ + calculateRanking(numbers, bonus) { + const {count, isBonus} = this.#countSameAndBonus(numbers, bonus); + for(let i = 1; i < Object.keys(OptionalConstant.WINNING_CONDITION).length + 1; i++) { + if(count === OptionalConstant.WINNING_CONDITION[i].WINNING_NUMBERS && + isBonus === OptionalConstant.WINNING_CONDITION[i].HAS_BONUS) return i; + } + return 0; + } + + /** + * + * @param numbers + * @param bonus + * @returns {{count: number, isBonus: boolean}} + */ + #countSameAndBonus(numbers, bonus) { + let count = 0; + let isBonus = false; + this.#numbers.forEach(v => { + if(numbers.includes(v)) count++; + if(bonus === v) isBonus = true; + }) + return {count: count, isBonus: isBonus}; + } +} + +export default Lotto; diff --git a/src/repository/LottoRepository.js b/src/repository/LottoRepository.js new file mode 100644 index 00000000..c459f216 --- /dev/null +++ b/src/repository/LottoRepository.js @@ -0,0 +1,66 @@ + +import Lotto from "../domain/Lotto.js"; +import {MissionUtils} from "@woowacourse/mission-utils"; +import OptionalConstant from "../config/OptionalConstant.js"; + +class LottoRepository { + #lottos + + constructor() { + this.#lottos = [] + } + + /** + * + * @param lotto + */ + save(lotto) { + this.#lottos.push(lotto); + } + + /** + * + * @param amount + */ + purchaseAs(amount) { + for (let i = 0; i < amount; i++) { + this.save(new Lotto(MissionUtils.Random.pickUniqueNumbersInRange( + OptionalConstant.LOTTO_NUMBER_RANGE.MIN, + OptionalConstant.LOTTO_NUMBER_RANGE.MAX, + OptionalConstant.LOTTO_COUNT))); + } + } + + /** + * + * @returns {*} + */ + findAllAsNumbers() { + return this.#lottos.map(v => v.getNumbers()); + } + + /** + * + * @param numbers + * @param bonusNumber + * @returns {*} + */ + countByRank(numbers, bonusNumber) { + return this.findAllRankingByNumbersAndBonusNumber(numbers, bonusNumber).reduce((a, c) => { + a[c] = (a[c] || 0) + 1; + return a; + }, {}); + } + + /** + * + * @param numbers + * @param bonusNumber + * @returns {*} + */ + findAllRankingByNumbersAndBonusNumber(numbers, bonusNumber) { + return this.#lottos.map((v) => v.calculateRanking(numbers, bonusNumber)); + } +} + +export default LottoRepository; \ No newline at end of file diff --git a/src/service/LottoService.js b/src/service/LottoService.js new file mode 100644 index 00000000..dc9114d3 --- /dev/null +++ b/src/service/LottoService.js @@ -0,0 +1,50 @@ +import LottoRepository from "../repository/LottoRepository.js"; +import OptionalConstant from "../config/OptionalConstant.js"; + +class LottoService { + #lottoRepository; + #purchasedMoney; + + /** + * + * @param lottoRepository + */ + constructor(lottoRepository = new LottoRepository()) { + this.#lottoRepository = lottoRepository; + this.#purchasedMoney = 0; + } + + /** + * + * @param money + */ + purchase(money) { + this.#purchasedMoney = money; + this.#lottoRepository.purchaseAs(Math.floor(money / OptionalConstant.LOTTO_PRICE)); + } + + /** + * + * @returns {*} + */ + getAllLottoNumbers() { + return this.#lottoRepository.findAllAsNumbers(); + } + + /** + * + * @param numbers + * @param bonusNumber + * @returns {Map} + */ + getCountByRankAsMap(numbers, bonusNumber) { + const countByRankMap = new Map(); + const countByRank = this.#lottoRepository.countByRank(numbers, bonusNumber); + for(let i = 0; i < Object.keys(OptionalConstant.WINNING_CONDITION).length + 1; i++) { + countByRankMap.set(i, countByRank[i]); + } + return countByRankMap + } +} + +export default LottoService; \ No newline at end of file diff --git a/src/util/LottoValidator.js b/src/util/LottoValidator.js new file mode 100644 index 00000000..8ef19222 --- /dev/null +++ b/src/util/LottoValidator.js @@ -0,0 +1,73 @@ +import ErrorMessage from "../config/ErrorMessage.js"; +import OptionalConstant from "../config/OptionalConstant.js"; + +class LottoValidator{ + /** + * + * @param input + * @param errorMessage + */ + validateAmount(input, errorMessage = ErrorMessage.POSITIVE) { + if(input < OptionalConstant.AMOUNT_CONDITION) throw new Error(errorMessage); + } + + /** + * + * @param input + */ + validateLottoNumbers(input) { + this.#validateCount(input); + this.validateDuplicate(input); + input.forEach(v => this.#validateRange(v)); + } + + /** + * + * @param input + * @param winningLotto + */ + validateBonusNumber(input, winningLotto) { + this.#validateRange(input); + this.#validateNotInNumbers(input, winningLotto); + } + + /** + * + * @param numbers + * @param errorMessage + */ + validateDuplicate(numbers, errorMessage = ErrorMessage.DUPLICATE) { + if(numbers.length !== new Set(numbers).size) throw new Error(errorMessage); + } + + /** + * + * @param input + * @param numbers + * @param errorMessage + */ + #validateNotInNumbers(input, numbers, errorMessage = ErrorMessage.BONUS_DUPLICATE) { + if(numbers.includes(input)) throw new Error(errorMessage); + } + + /** + * + * @param input + * @param errorMessage + */ + #validateCount(input, errorMessage = ErrorMessage.COUNT) { + if(input.length !== OptionalConstant.LOTTO_COUNT) throw new Error(errorMessage); + } + + /** + * + * @param input + * @param errorMessage + */ + #validateRange(input, errorMessage = ErrorMessage.RANGE) { + if(input < OptionalConstant.LOTTO_NUMBER_RANGE.MIN || + input > OptionalConstant.LOTTO_NUMBER_RANGE.MAX) throw new Error(errorMessage); + } +} + +export default LottoValidator; \ No newline at end of file