diff --git a/README.md b/README.md index bab3552..d33becb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 자동차 경주 미션 +# 로또 미션 ## 참고링크 및 저장소 diff --git a/package.json b/package.json index 1fe342c..224bbf7 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,12 @@ "version": "1.0.0", "description": "로또 미션을 통해서 학습하는 클린코드", "main": "./src/main.js", - "type": "module", + "type": "module", "scripts": { "start": "node src/main.js", "start:watch": "node --watch src/main.js", - "test": "vitest" + "test": "vitest run", + "test:watch": "vitest" }, "keywords": [], "author": "", diff --git a/src/__tests__/Lotto.test.js b/src/__tests__/Lotto.test.js new file mode 100644 index 0000000..6318151 --- /dev/null +++ b/src/__tests__/Lotto.test.js @@ -0,0 +1,38 @@ +import { describe, test, expect } from "vitest"; +import { Lotto } from "../model/index.js"; + +describe("Lotto 클래스 테스트.", () => { + test.each([ + { value: 1 }, + { value: "1" }, + { value: true }, + { value: null }, + { value: undefined }, + { value: {} }, + ])( + "로또 번호로 적합하지 않은 값($value)을 받으면 오류가 발생한다.", + ({ value }) => { + expect(() => new Lotto(value)).toThrowError( + "[ERR_002] Lotto 클래스의 생성자 인수는 배열이어야 합니다." + ); + } + ); + + test("로또 번호가 6개 초과이면 오류가 발생한다.", () => { + expect(() => new Lotto([1, 2, 3, 4, 5, 6, 7])).toThrowError( + "[ERR_002] Lotto 클래스의 생성자 인수의 길이는 6이어야 합니다." + ); + }); + + test("로또 번호가 6개 미만이면 오류가 발생한다.", () => { + expect(() => new Lotto([1, 2, 3, 4, 5])).toThrowError( + "[ERR_002] Lotto 클래스의 생성자 인수의 길이는 6이어야 합니다." + ); + }); + + test("로또 번호를 가져옵니다.", () => { + const lotto = new Lotto([1, 2, 3, 4, 5, 6]); + + expect(lotto.numbers).toEqual([1, 2, 3, 4, 5, 6]); + }); +}); diff --git a/src/__tests__/LottoMachine.test.js b/src/__tests__/LottoMachine.test.js new file mode 100644 index 0000000..0f62f03 --- /dev/null +++ b/src/__tests__/LottoMachine.test.js @@ -0,0 +1,38 @@ +import { describe, test, expect } from "vitest"; +import { LottoMachine } from "../model/index.js"; + +describe("LottoMachine 클래스 테스트", () => { + test("로또를 구입할 때 정수가 아닌 값을 받으면 오류가 발생한다.", () => { + expect(() => new LottoMachine("1")).toThrowError( + "[ERR_004] LottoMachine 클래스의 생성자 인수는 정수여야 합니다." + ); + }); + + test("로또를 구입할 때 1000원미만의 값을 받으면 오류가 발생한다.", () => { + expect(() => new LottoMachine(999)).toThrowError( + "[ERR_004] LottoMachine 클래스의 생성자 인수는 1000이상이어야 합니다." + ); + }); + + test.each([ + { price: 1001, count: 1 }, + { price: 2500, count: 2 }, + { price: 3999, count: 3 }, + { price: 10999, count: 10 }, + ])( + "로또를 $price원만큼 구매하면 $count장을 발행한다.", + ({ price, count }) => { + const lottoMachine = new LottoMachine(price); + + expect(lottoMachine.count).toBe(count); + } + ); + + test("무작위 번호 6개가 적힌 로또를 받는다.", () => { + const lottoMachine = new LottoMachine(1000); + + const lotto = lottoMachine.lottos[0]; + + expect(lotto.numbers).toHaveLength(6); + }); +}); diff --git a/src/__tests__/LottoMatcher.test.js b/src/__tests__/LottoMatcher.test.js new file mode 100644 index 0000000..cdb23b7 --- /dev/null +++ b/src/__tests__/LottoMatcher.test.js @@ -0,0 +1,85 @@ +import { describe, expect, test, vi } from "vitest"; +import { + LottoMachine, + WinningLotto, + LottoMatcher, + Lotto, +} from "../model/index.js"; +import { LOTTO } from "../constants/index.js"; + +describe("LottoMatcher 클래스 테스트", () => { + test("1등부터 5등까지 당첨된 수를 가져온다.", () => { + const { lottos } = new LottoMachine(6000); + const winningLotto = new WinningLotto([1, 2, 3, 4, 5, 6], 7); + + const { winningCounts } = new LottoMatcher(lottos, winningLotto); + + expect(winningCounts).toHaveProperty("1"); + expect(winningCounts).toHaveProperty("2"); + expect(winningCounts).toHaveProperty("3"); + expect(winningCounts).toHaveProperty("4"); + expect(winningCounts).toHaveProperty("5"); + + expect(typeof winningCounts[1]).toBe("number"); + expect(typeof winningCounts[2]).toBe("number"); + expect(typeof winningCounts[3]).toBe("number"); + expect(typeof winningCounts[4]).toBe("number"); + expect(typeof winningCounts[5]).toBe("number"); + }); + + test("로또 번호 중에 1등 당첨이 하나있다.", () => { + const { lottos } = createLottoMachineMock([[1, 2, 3, 4, 5, 6]]); + const winningLotto = new WinningLotto([1, 2, 3, 4, 5, 6], 7); + + const { winningCounts } = new LottoMatcher(lottos, winningLotto); + + expect(winningCounts).toHaveProperty("1", 1); + }); + + test("로또 번호 중에 2등 당첨이 하나있다.", () => { + const { lottos } = createLottoMachineMock([[1, 2, 3, 4, 5, 7]]); + const winningLotto = new WinningLotto([1, 2, 3, 4, 5, 6], 7); + + const { winningCounts } = new LottoMatcher(lottos, winningLotto); + + expect(winningCounts).toHaveProperty("2", 1); + }); + + test("로또 번호 중에 3등 당첨이 하나있다.", () => { + const { lottos } = createLottoMachineMock([[1, 2, 3, 4, 5, 8]]); + const winningLotto = new WinningLotto([1, 2, 3, 4, 5, 6], 7); + + const { winningCounts } = new LottoMatcher(lottos, winningLotto); + + expect(winningCounts).toHaveProperty("3", 1); + }); + + test("로또 번호 중에 4등 당첨이 하나있다.", () => { + const { lottos } = createLottoMachineMock([[1, 2, 3, 4, 8, 9]]); + const winningLotto = new WinningLotto([1, 2, 3, 4, 5, 6], 7); + + const { winningCounts } = new LottoMatcher(lottos, winningLotto); + + expect(winningCounts).toHaveProperty("4", 1); + }); + + test("로또 번호 중에 5등 당첨이 하나있다.", () => { + const { lottos } = createLottoMachineMock([[1, 2, 3, 8, 9, 10]]); + const winningLotto = new WinningLotto([1, 2, 3, 4, 5, 6], 7); + + const { winningCounts } = new LottoMatcher(lottos, winningLotto); + + expect(winningCounts).toHaveProperty("5", 1); + }); +}); + +function createLottoMachineMock(numbersArray) { + const lottos = numbersArray.map((numbers) => new Lotto(numbers)); + const lottoMachineMock = vi.fn(); + lottoMachineMock.mockReturnValueOnce({ + price: lottos.length * LOTTO.PRICE, + lottos, + }); + + return lottoMachineMock(); +} diff --git a/src/__tests__/LottoNumber.test.js b/src/__tests__/LottoNumber.test.js new file mode 100644 index 0000000..c89bdd0 --- /dev/null +++ b/src/__tests__/LottoNumber.test.js @@ -0,0 +1,41 @@ +import { describe, expect, test } from "vitest"; +import { LottoNumber } from "../model/index.js"; + +describe("LottoNumber 클래스 테스트", () => { + test.each([ + { value: "a" }, + { value: true }, + { value: false }, + { value: undefined }, + { value: null }, + { value: [] }, + { value: {} }, + ])( + "로또 번호로 적합하지 않은 값($value)을 할당하면 오류가 발생한다.", + ({ value }) => { + expect(() => new LottoNumber(value)).toThrowError( + "[ERR_001] LottoNumber 클래스의 생성자 인수는 정수여야 합니다." + ); + } + ); + + test("로또 번호가 1보다 작으면 오류가 발생한다.", () => { + expect(() => new LottoNumber(0)).toThrowError( + "[ERR_001] LottoNumber 클래스의 생성자 인수는 1이상이어야 합니다." + ); + }); + + test("로또 번호가 45보다 크면 오류가 발생한다.", () => { + expect(() => new LottoNumber(46)).toThrowError( + "[ERR_001] LottoNumber 클래스의 생성자 인수는 45이하여야 합니다." + ); + }); + + test("로또 번호를 정한다.", () => { + const number = 1; + + const lottoNumber = new LottoNumber(number); + + expect(lottoNumber.value).toBe(1); + }); +}); diff --git a/src/__tests__/LottoReturnCalculator.test.js b/src/__tests__/LottoReturnCalculator.test.js new file mode 100644 index 0000000..fa34d29 --- /dev/null +++ b/src/__tests__/LottoReturnCalculator.test.js @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vitest"; +import { LottoReturnCalculator } from "../model/index.js"; + +describe("LottoReturnCalculator 클래스 테스트", () => { + test("수익률을 가져온다.", () => { + const winningCounts = { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 1, + }; + const price = 2000; + + const { rateOfReturn } = new LottoReturnCalculator(winningCounts, price); + + expect(rateOfReturn).toBe(250); + }); +}); diff --git a/src/__tests__/WinningLotto.test.js b/src/__tests__/WinningLotto.test.js new file mode 100644 index 0000000..542ecc1 --- /dev/null +++ b/src/__tests__/WinningLotto.test.js @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; +import { WinningLotto } from "../model/index.js"; + +describe("WinningLotto 클래스 테스트", () => { + test("당첨 번호 중에 보너스 번호와 중복되는 번호가 있으면 오류가 발생한다.", () => { + const lottoNumbers = [1, 2, 3, 4, 5, 6]; + const bonusNumber = 6; + + expect(() => new WinningLotto(lottoNumbers, bonusNumber)).toThrowError( + "[ERR_003] WinningLotto 클래스의 생성자 인수인 winningNumbers 중에 bonusNumber와 중복됩니다." + ); + }); + + test("보너스 번호를 가져온다.", () => { + const lottoNumbers = [1, 2, 3, 4, 5, 6]; + const bonusNumber = 45; + + const winningLotto = new WinningLotto(lottoNumbers, bonusNumber); + + expect(winningLotto.bonusNumber).toBe(45); + }); +}); diff --git a/src/__tests__/sum.test.js b/src/__tests__/sum.test.js deleted file mode 100644 index efc011c..0000000 --- a/src/__tests__/sum.test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, test, expect } from "vitest"; - -function sum(...args) { - return args.reduce((a, b) => a+ b); -} - -describe('예제 테스트입니다.', () => { - test('sum > ', () => { - expect(sum(1,2,3,4,5)).toBe(15); - }) -}) diff --git a/src/constants/errorMessage.js b/src/constants/errorMessage.js new file mode 100644 index 0000000..3f3c946 --- /dev/null +++ b/src/constants/errorMessage.js @@ -0,0 +1,8 @@ +export const USER_FRIENDLY_ERROR_MESSAGES = { + ERR_001: + "입력하신 값은 로또 번호로 사용할 수 없습니다. 1부터 45 사이의 숫자를 입력해 주세요.", + ERR_002: "로또 번호는 중복 없이 6개의 서로 다른 숫자를 입력해 주세요.", + ERR_003: + "보너스 번호가 당첨 번호와 중복되었습니다. 당첨 번호 6개와 중복되지 않는 보너스 번호 1개를 포함하여 총 7개의 번호를 다시 입력해 주세요.", + ERR_004: "로또 구매 금액은 1,000원 단위로 입력해 주세요.", +}; diff --git a/src/constants/index.js b/src/constants/index.js new file mode 100644 index 0000000..d85d01d --- /dev/null +++ b/src/constants/index.js @@ -0,0 +1,2 @@ +export * from "./lotto.js"; +export * from "./errorMessage.js"; diff --git a/src/constants/lotto.js b/src/constants/lotto.js new file mode 100644 index 0000000..8b9766f --- /dev/null +++ b/src/constants/lotto.js @@ -0,0 +1,34 @@ +export const LOTTO = { + NUMBERS_SIZE: 6, + MIN_NUMBER: 1, + MAX_NUMBER: 45, + PRICE: 1000, + RANKING_INFO: [ + { + ranking: 1, + matchingCount: 6, + prizeMoney: 2_000_000_000, + }, + { + ranking: 2, + matchingCount: 5, + isBonusMatch: true, + prizeMoney: 30_000_000, + }, + { + ranking: 3, + matchingCount: 5, + prizeMoney: 1_500_000, + }, + { + ranking: 4, + matchingCount: 4, + prizeMoney: 50_000, + }, + { + ranking: 5, + matchingCount: 3, + prizeMoney: 5_000, + }, + ], +}; diff --git a/src/controller/calculateWinningResults.js b/src/controller/calculateWinningResults.js new file mode 100644 index 0000000..0fdc8ea --- /dev/null +++ b/src/controller/calculateWinningResults.js @@ -0,0 +1,14 @@ +import { LOTTO } from "../constants/index.js"; +import { LottoMatcher, LottoReturnCalculator } from "../model/index.js"; + +const calculateWinningResults = ({ lottos, winningLotto }) => { + const { winningCounts } = new LottoMatcher(lottos, winningLotto); + + const price = lottos.length * LOTTO.PRICE; + + const { rateOfReturn } = new LottoReturnCalculator(winningCounts, price); + + return { winningCounts, rateOfReturn }; +}; + +export default calculateWinningResults; diff --git a/src/controller/createWinningLotto.js b/src/controller/createWinningLotto.js new file mode 100644 index 0000000..90fcb21 --- /dev/null +++ b/src/controller/createWinningLotto.js @@ -0,0 +1,66 @@ +import { inputManager } from "../service/index.js"; +import { Lotto, LottoNumber, WinningLotto } from "../model/index.js"; +import { retryOnFailureAsync } from "../utils/index.js"; +import handleErrorAndPrint from "./handleErrorAndPrint.js"; + +const createWinningLotto = async () => { + const winningLotto = await retryOnFailureAsync(async () => { + const winningNumbers = await retryCreateWinningNumbers(); + const bonusNumber = await retryCreateBonusNumber(); + + return new WinningLotto(winningNumbers, bonusNumber); + }, handleErrorAndPrint); + + return winningLotto; +}; + +export default createWinningLotto; + +const retryCreateWinningNumbers = async () => { + const winningNumbers = await retryOnFailureAsync( + createWinningNumbers, + handleErrorAndPrint + ); + + return winningNumbers; +}; + +const createWinningNumbers = async () => { + const inputNumbers = await inputManager.scan( + "> 당첨 번호를 입력해 주세요. ", + (inputValue) => + inputValue + .trim() + .split(",") + .map((value) => value.trim()) + .map((value) => Number(value)) + ); + + const winningNumbers = new Lotto(inputNumbers); + + return winningNumbers.numbers; +}; + +const retryCreateBonusNumber = async () => { + const bonusNumber = await retryOnFailureAsync( + createBonusNumber, + handleErrorAndPrint + ); + + return bonusNumber; +}; + +const createBonusNumber = async () => { + const inputNumber = await inputManager.scan( + "> 보너스 번호를 입력해 주세요. ", + (inputValue) => { + const trimedInputValue = inputValue.trim(); + + return Number(trimedInputValue); + } + ); + + const bonusNumber = new LottoNumber(inputNumber); + + return bonusNumber.value; +}; diff --git a/src/controller/handleErrorAndPrint.js b/src/controller/handleErrorAndPrint.js new file mode 100644 index 0000000..f190631 --- /dev/null +++ b/src/controller/handleErrorAndPrint.js @@ -0,0 +1,14 @@ +import { getUserFriendlyErrorMessage } from "../utils/index.js"; +import { USER_FRIENDLY_ERROR_MESSAGES } from "../constants/index.js"; +import { outputManager } from "../service/index.js"; + +const handleErrorAndPrint = (error) => { + const errorMessage = getUserFriendlyErrorMessage( + error.message, + USER_FRIENDLY_ERROR_MESSAGES + ); + + outputManager.print(errorMessage); +}; + +export default handleErrorAndPrint; diff --git a/src/controller/index.js b/src/controller/index.js new file mode 100644 index 0000000..0544edf --- /dev/null +++ b/src/controller/index.js @@ -0,0 +1,5 @@ +export { default as purchaseLottos } from "./purchaseLottos.js"; +export { default as createWinningLotto } from "./createWinningLotto.js"; +export { default as calculateWinningResults } from "./calculateWinningResults.js"; +export { default as shouldRestartGame } from "./shouldRestartGame.js"; +export { default as handleErrorAndPrint } from "./handleErrorAndPrint.js"; diff --git a/src/controller/purchaseLottos.js b/src/controller/purchaseLottos.js new file mode 100644 index 0000000..c981f41 --- /dev/null +++ b/src/controller/purchaseLottos.js @@ -0,0 +1,23 @@ +import { LottoMachine } from "../model/index.js"; +import { inputManager } from "../service/index.js"; +import { retryOnFailureAsync } from "../utils/index.js"; +import handleErrorAndPrint from "./handleErrorAndPrint.js"; + +const purchaseLottos = async () => { + const { count, lottos } = await retryOnFailureAsync(async () => { + const priceValue = await inputManager.scan( + "> 구입 금액을 입력해 주세요. ", + (inputValue) => { + const trimedInputValue = inputValue.trim(); + + return Number(trimedInputValue); + } + ); + + return new LottoMachine(priceValue); + }, handleErrorAndPrint); + + return { count, lottos }; +}; + +export default purchaseLottos; diff --git a/src/controller/shouldRestartGame.js b/src/controller/shouldRestartGame.js new file mode 100644 index 0000000..1fc09d1 --- /dev/null +++ b/src/controller/shouldRestartGame.js @@ -0,0 +1,18 @@ +import { inputManager } from "../service/index.js"; + +const shouldRestartGame = async () => { + const restart = await confirmRestart(); + + return restart === "y"; +}; + +export default shouldRestartGame; + +const confirmRestart = async () => { + const inputValue = await inputManager.scan( + "다시 시작하시겠습니까? (Yes: y, No: 아무키) ", + (inputValue) => inputValue.trim().toLowerCase() + ); + + return inputValue; +}; diff --git a/src/main.js b/src/main.js index 96bab59..490f69a 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,25 @@ -function main() { - console.log('main의 내용을 채워주세요'); -} +import { + purchaseLottos, + createWinningLotto, + calculateWinningResults, + shouldRestartGame, +} from "./controller/index.js"; +import { showPurchasedLottos, showWinningResults } from "./view/index.js"; + +const main = async () => { + do { + const { count, lottos } = await purchaseLottos(); + + showPurchasedLottos(count, lottos); + + const winningLotto = await createWinningLotto(); + const { winningCounts, rateOfReturn } = calculateWinningResults({ + lottos, + winningLotto, + }); + + showWinningResults(winningCounts, rateOfReturn); + } while (await shouldRestartGame()); +}; main(); diff --git a/src/model/Lotto.js b/src/model/Lotto.js new file mode 100644 index 0000000..0737106 --- /dev/null +++ b/src/model/Lotto.js @@ -0,0 +1,40 @@ +import { LOTTO } from "../constants/index.js"; +import { + isDuplicated, + throwErrorWithCondition, + validate, +} from "../utils/index.js"; +import LottoNumber from "./LottoNumber.js"; + +class Lotto { + #lottoNumbers; + + constructor(numbers) { + Lotto.#validateNumbers(numbers); + + this.#lottoNumbers = numbers.map((number) => new LottoNumber(number)); + } + + get numbers() { + return this.#lottoNumbers.map((lottoNumber) => lottoNumber.value); + } + + static #validateNumbers(lottoNumbers) { + validate.array( + lottoNumbers, + "[ERR_002] Lotto 클래스의 생성자 인수는 배열이어야 합니다." + ); + + throwErrorWithCondition( + lottoNumbers.length !== LOTTO.NUMBERS_SIZE, + `[ERR_002] Lotto 클래스의 생성자 인수의 길이는 ${LOTTO.NUMBERS_SIZE}이어야 합니다.` + ); + + throwErrorWithCondition( + isDuplicated(lottoNumbers), + "[ERR_002] Lotto 클래스의 생성자 인수인 배열에 중복되는 값이 있습니다." + ); + } +} + +export default Lotto; diff --git a/src/model/LottoMachine.js b/src/model/LottoMachine.js new file mode 100644 index 0000000..fabd681 --- /dev/null +++ b/src/model/LottoMachine.js @@ -0,0 +1,62 @@ +import { LOTTO } from "../constants/index.js"; +import { + range, + throwErrorWithCondition, + validate, + shuffle, +} from "../utils/index.js"; +import Lotto from "./Lotto.js"; + +class LottoMachine { + #lottos; + + constructor(price) { + LottoMachine.#validatePrice(price); + + const count = LottoMachine.#countLotto(price); + this.#lottos = LottoMachine.#createLottos(count); + } + + get count() { + return this.#lottos.length; + } + + get lottos() { + return [...this.#lottos]; + } + + static #validatePrice(price) { + validate.integer( + price, + "[ERR_004] LottoMachine 클래스의 생성자 인수는 정수여야 합니다." + ); + + throwErrorWithCondition( + price < LOTTO.PRICE, + `[ERR_004] LottoMachine 클래스의 생성자 인수는 ${LOTTO.PRICE}이상이어야 합니다.` + ); + } + + static #countLotto(price) { + return Math.floor(price / LOTTO.PRICE); + } + + static #createLottos(count) { + return Array.from({ length: count }).map(LottoMachine.#createLotto); + } + + static #createLotto() { + const lottoNumbers = LottoMachine.#getLottoNumbers(); + const sortedlottoNumbers = [...lottoNumbers].sort((a, b) => a - b); + + return new Lotto(sortedlottoNumbers); + } + + static #getLottoNumbers() { + const lottoNumbers = range(LOTTO.MIN_NUMBER, LOTTO.MAX_NUMBER + 1); + + return shuffle(lottoNumbers).slice(0, LOTTO.NUMBERS_SIZE); + } +} + +export default LottoMachine; diff --git a/src/model/LottoMatcher.js b/src/model/LottoMatcher.js new file mode 100644 index 0000000..12bd522 --- /dev/null +++ b/src/model/LottoMatcher.js @@ -0,0 +1,67 @@ +import { LOTTO } from "../constants/index.js"; + +class LottoMatcher { + #winningCounts; + + constructor(lottos, winningLotto) { + this.#winningCounts = LottoMatcher.#calculateWinningCounts( + lottos, + winningLotto + ); + } + + get winningCounts() { + return { ...this.#winningCounts }; + } + + static #calculateWinningCounts(lottos, winningLotto) { + const initialCounts = { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }; + + const lottoMatchResults = lottos.map((lotto) => ({ + matchingCount: LottoMatcher.#getMatchingCount( + lotto.numbers, + winningLotto.numbers + ), + isBonusMatch: lotto.numbers.includes(winningLotto.bonusNumber), + })); + + return LottoMatcher.#updateWinningCounts(lottoMatchResults, initialCounts); + } + + static #getMatchingCount(lottoNumber, winningLottoNumber) { + return lottoNumber.filter((number) => winningLottoNumber.includes(number)) + .length; + } + + static #updateWinningCounts(lottoMatchResults, winningCounts) { + return lottoMatchResults.reduce(LottoMatcher.#updateCounts, winningCounts); + } + + static #updateCounts(counts, { matchingCount, isBonusMatch }) { + const ranking = LottoMatcher.#getRanking(matchingCount, isBonusMatch); + + if (ranking) { + counts[ranking] += 1; + } + + return counts; + } + + static #getRanking(matchingCount, isBonusMatch) { + const currentInfo = LOTTO.RANKING_INFO.find( + (info) => + info.matchingCount === matchingCount && + Boolean(info.isBonusMatch) === isBonusMatch + ); + + return currentInfo && currentInfo.ranking; + } +} + +export default LottoMatcher; diff --git a/src/model/LottoNumber.js b/src/model/LottoNumber.js new file mode 100644 index 0000000..220776f --- /dev/null +++ b/src/model/LottoNumber.js @@ -0,0 +1,35 @@ +import { throwErrorWithCondition, validate } from "../utils/index.js"; +import { LOTTO } from "../constants/index.js"; + +class LottoNumber { + #number; + + constructor(number) { + LottoNumber.#validateNumber(number); + + this.#number = number; + } + + get value() { + return this.#number; + } + + static #validateNumber(number) { + validate.integer( + number, + "[ERR_001] LottoNumber 클래스의 생성자 인수는 정수여야 합니다." + ); + + throwErrorWithCondition( + number < LOTTO.MIN_NUMBER, + `[ERR_001] LottoNumber 클래스의 생성자 인수는 ${LOTTO.MIN_NUMBER}이상이어야 합니다.` + ); + + throwErrorWithCondition( + LOTTO.MAX_NUMBER < number, + `[ERR_001] LottoNumber 클래스의 생성자 인수는 ${LOTTO.MAX_NUMBER}이하여야 합니다.` + ); + } +} + +export default LottoNumber; diff --git a/src/model/LottoReturnCalculator.js b/src/model/LottoReturnCalculator.js new file mode 100644 index 0000000..613276c --- /dev/null +++ b/src/model/LottoReturnCalculator.js @@ -0,0 +1,35 @@ +import { LOTTO } from "../constants/index.js"; +import { formatNumber } from "../utils/index.js"; + +class LottoReturnCalculator { + #rateOfReturn; + + constructor(winningCounts, price) { + this.#rateOfReturn = LottoReturnCalculator.#calcRateOfReturn( + winningCounts, + price + ); + } + + get rateOfReturn() { + return formatNumber(this.#rateOfReturn); + } + + static #calcRateOfReturn(winningCounts, price) { + const sumOfPrize = Object.entries({ ...winningCounts }) + .map( + ([ranking, count]) => + LottoReturnCalculator.#getPrizeMoney(ranking) * count + ) + .reduce((acc, cur) => acc + cur, 0); + + return (sumOfPrize / price) * 100; + } + + static #getPrizeMoney(ranking) { + return LOTTO.RANKING_INFO.find((info) => info.ranking === Number(ranking)) + .prizeMoney; + } +} + +export default LottoReturnCalculator; diff --git a/src/model/WinningLotto.js b/src/model/WinningLotto.js new file mode 100644 index 0000000..f5028f8 --- /dev/null +++ b/src/model/WinningLotto.js @@ -0,0 +1,27 @@ +import { isDuplicated, throwErrorWithCondition } from "../utils/index.js"; +import Lotto from "./Lotto.js"; + +class WinningLotto extends Lotto { + #bonusNumber; + + constructor(numbers, bonusNumber) { + super(numbers); + + WinningLotto.#validateBonusNumber(this.numbers, bonusNumber); + + this.#bonusNumber = bonusNumber; + } + + get bonusNumber() { + return this.#bonusNumber; + } + + static #validateBonusNumber(winningNumbers, bonusNumber) { + throwErrorWithCondition( + isDuplicated(winningNumbers.concat(bonusNumber)), + "[ERR_003] WinningLotto 클래스의 생성자 인수인 winningNumbers 중에 bonusNumber와 중복됩니다." + ); + } +} + +export default WinningLotto; diff --git a/src/model/index.js b/src/model/index.js new file mode 100644 index 0000000..de53d0a --- /dev/null +++ b/src/model/index.js @@ -0,0 +1,6 @@ +export { default as Lotto } from "./Lotto.js"; +export { default as LottoMachine } from "./LottoMachine.js"; +export { default as WinningLotto } from "./WinningLotto.js"; +export { default as LottoNumber } from "./LottoNumber.js"; +export { default as LottoMatcher } from "./LottoMatcher.js"; +export { default as LottoReturnCalculator } from "./LottoReturnCalculator.js"; diff --git a/src/service/index.js b/src/service/index.js new file mode 100644 index 0000000..c09c67f --- /dev/null +++ b/src/service/index.js @@ -0,0 +1,2 @@ +export { default as inputManager } from "./inputManager.js"; +export { default as outputManager } from "./outputManager.js"; diff --git a/src/service/inputManager.js b/src/service/inputManager.js new file mode 100644 index 0000000..d7584c9 --- /dev/null +++ b/src/service/inputManager.js @@ -0,0 +1,19 @@ +import { readLineAsync } from "../utils/index.js"; + +class InputManager { + #inputFn; + + constructor(inputFn) { + this.#inputFn = inputFn; + } + + async scan(query, processFn) { + const inputValue = await this.#inputFn(query); + + return typeof processFn === "function" ? processFn(inputValue) : inputValue; + } +} + +const inputManager = new InputManager(readLineAsync); + +export default inputManager; diff --git a/src/service/outputManager.js b/src/service/outputManager.js new file mode 100644 index 0000000..23f866a --- /dev/null +++ b/src/service/outputManager.js @@ -0,0 +1,28 @@ +class OutputManager { + #outputFn; + + constructor(outputFn) { + this.#outputFn = outputFn; + } + + print(value) { + this.#outputFn(`${value}`); + } + + printAll(values, processFn) { + values.forEach((value) => { + const resultToPrint = + typeof processFn === "function" ? processFn(value) : value; + + this.#outputFn(resultToPrint); + }); + } + + linebreak() { + this.#outputFn(""); + } +} + +const outputManager = new OutputManager(console.log); + +export default outputManager; diff --git a/src/utils/arrayUtils.js b/src/utils/arrayUtils.js new file mode 100644 index 0000000..2402546 --- /dev/null +++ b/src/utils/arrayUtils.js @@ -0,0 +1,23 @@ +export const range = (start, end) => { + if (end === undefined) { + end = start; + start = 0; + } + + const length = end - start; + const result = Array(length); + + for (let i = 0; i < length; i++) { + result[i] = start + i; + } + + return result; +}; + +export const shuffle = (array) => { + const result = [...array]; + + result.sort(() => 0.5 - Math.random()); + + return result; +}; diff --git a/src/utils/formatKoreanCurrency.js b/src/utils/formatKoreanCurrency.js new file mode 100644 index 0000000..a5041e5 --- /dev/null +++ b/src/utils/formatKoreanCurrency.js @@ -0,0 +1,5 @@ +const formatKoreanCurrency = (number) => { + return new Intl.NumberFormat("ko-KR").format(number); +}; + +export default formatKoreanCurrency; diff --git a/src/utils/formatNumber.js b/src/utils/formatNumber.js new file mode 100644 index 0000000..c51c8d6 --- /dev/null +++ b/src/utils/formatNumber.js @@ -0,0 +1,5 @@ +const formatNumber = (number, digits = 2) => { + return Number.isInteger(number) ? number : number.toFixed(digits); +}; + +export default formatNumber; diff --git a/src/utils/getUserFriendlyErrorMessage.js b/src/utils/getUserFriendlyErrorMessage.js new file mode 100644 index 0000000..c103beb --- /dev/null +++ b/src/utils/getUserFriendlyErrorMessage.js @@ -0,0 +1,8 @@ +const getUserFriendlyErrorMessage = (errorMessage, userFriendlyMessages) => { + const match = errorMessage.match(/^\[(ERR_\d{3})\]/); + const errorCode = match && match[1]; + + return (errorCode && userFriendlyMessages[errorCode]) || errorMessage; +}; + +export default getUserFriendlyErrorMessage; diff --git a/src/utils/index.js b/src/utils/index.js new file mode 100644 index 0000000..13c0f93 --- /dev/null +++ b/src/utils/index.js @@ -0,0 +1,8 @@ +export { default as isDuplicated } from "./isDuplicated.js"; +export { default as readLineAsync } from "./readLineAsync.js"; +export * from "./validate.js"; +export { default as retryOnFailureAsync } from "./retryOnFailureAsync.js"; +export { default as formatKoreanCurrency } from "./formatKoreanCurrency.js"; +export * from "./arrayUtils.js"; +export { default as getUserFriendlyErrorMessage } from "./getUserFriendlyErrorMessage.js"; +export { default as formatNumber } from "./formatNumber.js"; diff --git a/src/utils/isDuplicated.js b/src/utils/isDuplicated.js new file mode 100644 index 0000000..c80df74 --- /dev/null +++ b/src/utils/isDuplicated.js @@ -0,0 +1,7 @@ +const isDuplicated = (array) => { + const set = new Set(array); + + return set.size !== array.length; +}; + +export default isDuplicated; diff --git a/src/utils/readLineAsync.js b/src/utils/readLineAsync.js new file mode 100644 index 0000000..d06c644 --- /dev/null +++ b/src/utils/readLineAsync.js @@ -0,0 +1,21 @@ +import readline from "readline"; + +const readLineAsync = (query) => { + return new Promise((resolve, reject) => { + if (typeof query !== "string") { + reject(new Error("query must be string")); + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question(query, (input) => { + rl.close(); + resolve(input); + }); + }); +}; + +export default readLineAsync; diff --git a/src/utils/retryOnFailureAsync.js b/src/utils/retryOnFailureAsync.js new file mode 100644 index 0000000..5cc66ca --- /dev/null +++ b/src/utils/retryOnFailureAsync.js @@ -0,0 +1,11 @@ +const retryOnFailureAsync = async (asyncFn, errorFn) => { + try { + return await asyncFn(); + } catch (error) { + errorFn(error); + + return await retryOnFailureAsync(asyncFn, errorFn); + } +}; + +export default retryOnFailureAsync; diff --git a/src/utils/validate.js b/src/utils/validate.js new file mode 100644 index 0000000..83a1e91 --- /dev/null +++ b/src/utils/validate.js @@ -0,0 +1,17 @@ +export const throwErrorWithCondition = (condition, errorMessage) => { + if (condition) { + throw new Error(errorMessage); + } +}; + +export const validate = { + type(value, typeValue, errorMessage) { + throwErrorWithCondition(typeof value !== typeValue, errorMessage); + }, + integer(value, errorMessage) { + throwErrorWithCondition(!Number.isInteger(value), errorMessage); + }, + array(value, errorMessage) { + throwErrorWithCondition(!Array.isArray(value), errorMessage); + }, +}; diff --git a/src/view/index.js b/src/view/index.js new file mode 100644 index 0000000..d61e2b4 --- /dev/null +++ b/src/view/index.js @@ -0,0 +1,2 @@ +export { default as showPurchasedLottos } from "./showPurchasedLottos.js"; +export { default as showWinningResults } from "./showWinningResults.js"; diff --git a/src/view/showPurchasedLottos.js b/src/view/showPurchasedLottos.js new file mode 100644 index 0000000..ac40460 --- /dev/null +++ b/src/view/showPurchasedLottos.js @@ -0,0 +1,8 @@ +import { outputManager } from "../service/index.js"; + +const showPurchasedLottos = (count, lottos) => { + outputManager.print(`${count}개 구매했습니다.`); + outputManager.printAll(lottos, (lottos) => lottos.numbers); +}; + +export default showPurchasedLottos; diff --git a/src/view/showWinningResults.js b/src/view/showWinningResults.js new file mode 100644 index 0000000..d985c7d --- /dev/null +++ b/src/view/showWinningResults.js @@ -0,0 +1,23 @@ +import { LOTTO } from "../constants/index.js"; +import { outputManager } from "../service/index.js"; +import { formatKoreanCurrency } from "../utils/index.js"; + +const showWinningResults = (winningCounts, rateOfReturn) => { + outputManager.print("당첨 통계"); + outputManager.print("--------------------"); + + LOTTO.RANKING_INFO.reverse().forEach( + ({ ranking, matchingCount, isBonusMatch, prizeMoney }) => { + const bonusMatchMessage = isBonusMatch ? `, 보너스 볼 일치` : ""; + const formattedPrizeMoney = formatKoreanCurrency(prizeMoney); + + outputManager.print( + `${matchingCount}개 일치${bonusMatchMessage} (${formattedPrizeMoney}원) - ${winningCounts[ranking]}개` + ); + } + ); + + outputManager.print(`총 수익률은 ${rateOfReturn}%입니다.`); +}; + +export default showWinningResults;