diff --git a/README.md b/README.md index b168a180..22e5b5aa 100644 --- a/README.md +++ b/README.md @@ -1 +1,10 @@ # javascript-planetlotto-precourse + +## 기능 목록 + +- [x] 구입 금액에 해당하는 만큼 발행할 로또 수량을 정한다. +- [x] 로또 수량만큼 중복되지 않은 5개의 숫자로 구성된 로또를 발행한다. +- [ ] 당첨 번호와 발행된 로또를 비교한다. +- [ ] 4개의 번호가 일치하면 보너스 번호와 비교한다. +- [ ] 비교된 결과값을 바탕으로 당첨 내역을 계산한다. +- [ ] 사용자가 잘못된 값을 입력하면 에러 메시지와 Error를 발생시키고 재입력을 받는다. diff --git a/__tests__/LottoMachineTest.js b/__tests__/LottoMachineTest.js new file mode 100644 index 00000000..21ba3aad --- /dev/null +++ b/__tests__/LottoMachineTest.js @@ -0,0 +1,15 @@ +import LottoMachine from "../src/model/LottoMachine.js"; + +describe("LottoMachine 클래스 테스트", () => { + test("로또 가격만큼 나누어 떨어지지 않으면 에러를 발생한다.", () => { + const purchase = 600; + expect(() => { + new LottoMachine({ purchase }); + }).toThrow("INVALID_PURCHASE"); + }); + + test("로또 가격만큼 발행할 로또 수량을 계산한다.", () => { + const purchase = 1000; + expect(new LottoMachine({ purchase }).getQuantity()).toBe(2); + }); +}); diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js new file mode 100644 index 00000000..d5d68084 --- /dev/null +++ b/__tests__/LottoTest.js @@ -0,0 +1,31 @@ +import Lotto from "../src/model/Lotto.js"; + +describe("Lotto 클래스 테스트", () => { + test("중복된 숫자가 있으면 에러를 발생한다.", () => { + expect(() => { + new Lotto([1, 2, 3, 4, 4]); + }).toThrow("NUMBERS_DUPLICATION"); + }); + + test("1 ~ 30 사이의 숫자 범위에 들지 않으면 에러를 발생한다.", () => { + expect(() => { + new Lotto([1, 2, 3, 4, 31]); + }).toThrow("NUMBERIC_RANG"); + }); + + test("로또를 오름차순으로 반환한다.", () => { + expect(new Lotto([30, 1, 5, 3, 22]).getNumbers()).toEqual([ + 1, 3, 5, 22, 30, + ]); + }); + + test("비교할 로또와 같은 숫자들을 가지면 true를 반환한다.", () => { + expect( + new Lotto([1, 4, 8, 29, 10]).isEqual([1, 4, 8, 10, 29]) + ).toBeTruthy(); + }); + + test("비교할 로또와 다른 숫자들을 가지면 false를 반환한다.", () => { + expect(new Lotto([1, 4, 7, 10, 29]).isEqual([1, 4, 7, 10, 5])).toBeFalsy(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 328e25a1..8b17a6cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2985,6 +2986,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001669", "electron-to-chromium": "^1.5.41", diff --git a/src/App.js b/src/App.js index 091aa0a5..c69f514a 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,17 @@ +import { InputView } from "./view.js"; +import { OutputView } from "./view.js"; +import LottoController from "./application/LottoController.js"; +import LottoService from "./application/LottoService.js"; + class App { - async run() {} + async run() { + const lottoController = new LottoController({ + InputView, + OutputView, + LottoService, + }); + await lottoController.run(); + } } export default App; diff --git a/src/application/LottoController.js b/src/application/LottoController.js new file mode 100644 index 00000000..92c3793a --- /dev/null +++ b/src/application/LottoController.js @@ -0,0 +1,52 @@ +import { DomainError, InvalidWinningError } from "../error/ApplicationError.js"; + +export default class LottoController { + #inputView; + #outputView; + #LottoService; + + constructor({ InputView, OutputView, LottoService } = {}) { + this.#inputView = InputView; + this.#outputView = OutputView; + this.#LottoService = LottoService; + } + + async #handlingError(fn) { + while (true) { + try { + return await fn(); + } catch (error) { + const message = `[ERROR] ${error.message}`; + + this.#outputView.printErrorMessage(message); + + if (error instanceof DomainError) continue; + if (error instanceof InvalidWinningError) this.#askWinningLotto(); + + throw error; + } + } + } + + async #askWinningLotto() { + return await this.#handlingError(() => this.#inputView.askWinningLotto()); + } + + async run() { + const amount = await this.#handlingError(() => this.#inputView.askAmount()); + const winningLotto = this.#askWinningLotto(); + const bonus = await this.#handlingError(() => + this.#inputView.askBonusNumber() + ); + + const service = await this.#handlingError(() => this.#LottoService()); + const { issueLottos, rankingCounts } = service.run( + amount, + winningLotto, + bonus + ); + + this.#outputView.printPurchasedLottos(issueLottos); + this.#outputView.printResult(rankingCounts); + } +} diff --git a/src/application/LottoService.js b/src/application/LottoService.js new file mode 100644 index 00000000..bbddc294 --- /dev/null +++ b/src/application/LottoService.js @@ -0,0 +1,31 @@ +import LottoMachine from "../model/LottoMachine.js"; +import LottoWinning from "../model/LottoWinning.js"; +import LottoWinningRank from "../model/LottoWinningRank.js"; +import WinnersStatistics from "../model/WinnersStatistics.js"; + +export default class LottoService { + constructor() {} + + run(purchase, winningNumbers, bonus) { + const lottoMachine = new LottoMachine(purchase); + const issueLottos = lottoMachine.issue(); + + const lottoWinning = new LottoWinning({ + winningNumbers, + bonus, + lottoWinningRank: LottoWinningRank, + }); + + const winningRankings = issueLottos.map((lotto) => + lottoWinning.evaluate(lotto.getNumbers()) + ); + + const winnersStatistics = new WinnersStatistics(winningRankings); + + const rankings = LottoWinningRank.getRanks(); + + const rankingCounts = winnersStatistics.getStatistics(rankings); + + return { issueLottos, rankingCounts }; + } +} diff --git a/src/constants/errorMessage.js b/src/constants/errorMessage.js new file mode 100644 index 00000000..1dce2922 --- /dev/null +++ b/src/constants/errorMessage.js @@ -0,0 +1,7 @@ +const ERROR_TYPES = { + NUMBERS_DUPLICATION: "중복되는 숫자가 있습니다.", + NUMBERIC_RANG: "숫자 범위에서 벗어났습니다.", + INVALID_PURCHASE: "로또 가격에 해당하는 가격을 입력해주세요.", +}; + +export default ERROR_TYPES; diff --git a/src/error/ApplicationError.js b/src/error/ApplicationError.js new file mode 100644 index 00000000..370eb4ec --- /dev/null +++ b/src/error/ApplicationError.js @@ -0,0 +1,17 @@ +class ApplicationError extends Error { + constructor(message) { + super(message); + } +} + +export class DomainError extends ApplicationError { + constructor(message) { + super(`${message}`); + } +} + +export class InvalidWinningError extends ApplicationError { + constructor(message) { + super(`${message}`); + } +} diff --git a/src/model/Lotto.js b/src/model/Lotto.js new file mode 100644 index 00000000..e9e1eff2 --- /dev/null +++ b/src/model/Lotto.js @@ -0,0 +1,28 @@ +import { DomainError } from "../error/ApplicationError.js"; +import ERROR_TYPES from "../constants/errorMessage.js"; + +export default class Lotto { + #numbers; + + constructor(numbers) { + this.#validateNumbers(numbers); + + this.#numbers = numbers; + } + + #validateNumbers(numbers) { + if (new Set(numbers).size !== numbers.length) + throw new DomainError(ERROR_TYPES.NUMBERS_DUPLICATION); + + const isNumberRang = numbers.every((number) => 1 <= number && number <= 30); + if (!isNumberRang) throw new DomainError(ERROR_TYPES.NUMBERIC_RANG); + } + + getNumbers() { + return this.#numbers.sort((a, b) => a - b); + } + + isEqual(compareLotto) { + return this.#numbers.every((number) => compareLotto.includes(number)); + } +} diff --git a/src/model/LottoMachine.js b/src/model/LottoMachine.js new file mode 100644 index 00000000..dd4477bb --- /dev/null +++ b/src/model/LottoMachine.js @@ -0,0 +1,45 @@ +import randomPickUniqueNumbers from "../utils/randompickUniqueNumbers.js"; +import LottoFactory from "../utils/LottoFactory.js"; + +import { DomainError } from "../error/ApplicationError.js"; +import ERROR_TYPES from "../constants/errorMessage.js"; + +export default class LottoMachine { + #quantity; + #lottoFactory; + #randomFn; + + constructor({ + purchase, + lottoFactory = LottoFactory, + randomFn = randomPickUniqueNumbers, + } = {}) { + this.#validatePurchase(purchase); + + this.#quantity = purchase / 500; + this.#lottoFactory = lottoFactory; + this.#randomFn = randomFn; + } + + #validatePurchase(purchase) { + if (purchase % 500 !== 0) + throw new DomainError(ERROR_TYPES.INVALID_PURCHASE); + } + + #checkAvailable(issueLottos, newLotto) { + return issueLottos.every((lotto) => lotto.isEqual(newLotto)); + } + + issue() { + const issueLottos = []; + + while (issueLottos.length < this.#quantity) { + const newLotto = this.#lottoFactory.createLotto(this.#randomFn()); + const isAvailable = this.#checkAvailable(issueLottos, newLotto); + + if (isAvailable) issueLottos.push(newLotto); + } + + return issueLottos; + } +} diff --git a/src/model/LottoWinning.js b/src/model/LottoWinning.js new file mode 100644 index 00000000..db429a1a --- /dev/null +++ b/src/model/LottoWinning.js @@ -0,0 +1,53 @@ +import ERROR_TYPES from "../constants/errorMessage.js"; +import { DomainError, InvalidWinningError } from "../error/ApplicationError.js"; + +export default class LottoWinning { + #winningNumbers; + #bonus; + #lottoWinningRank; + + constructor({ winningNumbers, bonus, lottoWinningRank } = {}) { + this.#validateBonus(bonus); + + this.#winningNumbers = winningNumbers; + this.#bonus = bonus; + this.#lottoWinningRank = lottoWinningRank; + } + + validateWinningNumbers(winningNumbers) { + if (new Set(winningNumbers).size !== winningNumbers.length) + throw new InvalidWinningError(ERROR_TYPES.NUMBERS_DUPLICATION); + + const isNumberRang = winningNumbers.every( + (number) => 1 <= number && number <= 30 + ); + if (!isNumberRang) throw new InvalidWinningError(ERROR_TYPES.NUMBERIC_RANG); + } + + #validateBonus(bonus) { + const isNumberRange = 1 <= bonus && bonus <= 30; + if (!isNumberRange) + throw new DomainError("1 ~ 30 사이의 숫자 범위에 해당하지 않습니다."); + + if (this.#winningNumbers.includes(bonus)) { + throw new DomainError("당첨 번호에 중복되는 보너스 번호입니다."); + } + } + + #matchCount(lottoNumbers) { + return lottoNumbers.filter((number) => + this.#winningNumbers.includes(number) + ); + } + + #matchBonus(lottoNumbers, matchCount) { + return matchCount === 5 && lottoNumbers.includes(this.#bonus); + } + + evaluate(lottoNumbers) { + const matchCount = this.#matchCount(lottoNumbers); + const isMatchBonus = this.#matchBonus(lottoNumbers, matchCount); + + return this.#lottoWinningRank.evaluate(matchCount, isMatchBonus); + } +} diff --git a/src/model/LottoWinningRank.js b/src/model/LottoWinningRank.js new file mode 100644 index 00000000..b8d7884c --- /dev/null +++ b/src/model/LottoWinningRank.js @@ -0,0 +1,56 @@ +export default class LottoWinningRank { + #rank; + #matchCount; + #isMatchBonus; + #prize; + + constructor(rank, matchCount, isMatchBonus, prize) { + this.#rank = rank; + this.#matchCount = matchCount; + this.#isMatchBonus = isMatchBonus; + this.#prize = prize; + } + + getRank() { + return this.#rank; + } + + getMatchCount() { + return this.#matchCount; + } + + getIsMatchBonus() { + return this.#isMatchBonus; + } + + getPrize() { + return this.#prize; + } + + getRanks() { + return [ + LottoWinningRank.FIRST.getRank(), + LottoWinningRank.SECOND.getRank(), + LottoWinningRank.THIRD.getRank(), + LottoWinningRank.FOURTH.getRank(), + LottoWinningRank.FIFTH.getRank(), + LottoWinningRank.NONE.getRank(), + ]; + } + + static FIRST = new LottoWinningRank(1, 5, false, 100000000); + static SECOND = new LottoWinningRank(2, 4, true, 10000000); + static THIRD = new LottoWinningRank(3, 4, false, 1500000); + static FOURTH = new LottoWinningRank(4, 3, true, 500000); + static FIFTH = new LottoWinningRank(5, 2, true, 5000); + static NONE = new LottoWinningRank(0, 0, false, 0); + + static of(matchCount, isMatchBonus) { + if (matchCount === 5) return LottoWinningRank.FIRST; + if (matchCount === 4 && isMatchBonus) return LottoWinningRank.SECOND; + if (matchCount === 4) return LottoWinningRank.THIRD; + if (matchCount === 3 && isMatchBonus) return LottoWinningRank.FOURTH; + if (matchCount === 2 && isMatchBonus) return LottoWinningRank.FIFTH; + return LottoWinningRank.NONE; + } +} diff --git a/src/model/WinnersStatistics.js b/src/model/WinnersStatistics.js new file mode 100644 index 00000000..64cca4ea --- /dev/null +++ b/src/model/WinnersStatistics.js @@ -0,0 +1,20 @@ +export default class WinnersStatistics { + #ranks; + + constructor(ranks) { + this.#ranks = ranks; + } + + #count(ranking) { + return this.#ranks.filter((rank) => rank.getRank() === ranking).length; + } + + getStatistics(ranks) { + const rankingCounts = new Map(); + + for (const rank of ranks) { + const count = this.#count(rank); + rankingCounts.set(rank, count); + } + } +} diff --git a/src/utils/LottoFactory.js b/src/utils/LottoFactory.js new file mode 100644 index 00000000..3211eddb --- /dev/null +++ b/src/utils/LottoFactory.js @@ -0,0 +1,7 @@ +import Lotto from "../model/Lotto.js"; + +export default class LottoFactory { + static createLotto(numbers) { + return new Lotto(numbers); + } +} diff --git a/src/utils/randomPickUniqueNumbers.js b/src/utils/randomPickUniqueNumbers.js new file mode 100644 index 00000000..81cd4088 --- /dev/null +++ b/src/utils/randomPickUniqueNumbers.js @@ -0,0 +1,6 @@ +import { MissionUtils } from "@woowacourse/mission-utils"; + +const randomPickUniqueNumbers = () => + MissionUtils.Random.pickUniqueNumbersInRange(1, 30, 5); + +export default randomPickUniqueNumbers;