diff --git a/README.md b/README.md index 15bb106b5..f2eec45b8 100644 --- a/README.md +++ b/README.md @@ -1 +1,31 @@ # javascript-lotto-precourse +- 입력/출력/예외 처리 기본 기능 + - 입력받기 + - 출력하기 + - 예외처리 + +- 입/출력 값 처리 + - 입력: 입력 파싱 & Validating & 예외처리 + - 금액 -> 횟수 + - 로또 번호 + - 보너스 번호 + - 출력 + +- 로또 기능 + - 로또 번호 뽑기 + - 1회차 + - n회차 + - 당첨 결과 계산 + +- prize + - 몇등까지 있는가? + - 각 등수에 따른 당첨 기준 & 수령액 + +- 로또 번호 맞추기 + - 1회차 + - n회차 반복 + +- 수익금 계산 + - + + diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js index 872380c9c..e131d0c40 100644 --- a/__tests__/ApplicationTest.js +++ b/__tests__/ApplicationTest.js @@ -94,4 +94,74 @@ describe("로또 테스트", () => { test("예외 테스트", async () => { await runException("1000j"); }); + + test("예외 테스트: 재실행 테스트 - 구입금액 오류", async () => { + try { + mockRandoms([ + [8, 21, 23, 41, 42, 43], + [3, 5, 11, 16, 32, 38], + [7, 11, 16, 35, 36, 44], + [1, 8, 11, 31, 41, 42], + [13, 14, 16, 38, 42, 45], + [7, 11, 30, 40, 42, 43], + [2, 13, 22, 32, 38, 45], + [1, 3, 5, 14, 22, 45], + ]); + mockQuestions(["8a", "8000", "1,2,3,4,5,6", "7"]); + + const app = new App(); + await app.run(); + + expect(MissionUtils.Console.readLineAsync).toHaveBeenCalledWith('구입금액을 입력해 주세요.'); + expect(MissionUtils.Console.readLineAsync).toHaveBeenCalledTimes(4); + } catch(e) { + expect(() => app.toThrow()); + } + }); + + test("예외 테스트: 재실행 테스트 - 당첨 번호 오류", async () => { + try { + mockRandoms([ + [8, 21, 23, 41, 42, 43], + [3, 5, 11, 16, 32, 38], + [7, 11, 16, 35, 36, 44], + [1, 8, 11, 31, 41, 42], + [13, 14, 16, 38, 42, 45], + [7, 11, 30, 40, 42, 43], + [2, 13, 22, 32, 38, 45], + [1, 3, 5, 14, 22, 45], + ]); + mockQuestions(["8000", "1,2,3,4,5,a", "1,2,3,4,5,6", "7"]); + + const app = new App(); + await app.run(); + + expect(MissionUtils.Console.readLineAsync).toHaveBeenCalledTimes(4); + } catch(e) { + expect(() => app.toThrow()); + } + }); + + test("예외 테스트: 재실행 테스트 - 보너스 번호 오류", async () => { + try { + mockRandoms([ + [8, 21, 23, 41, 42, 43], + [3, 5, 11, 16, 32, 38], + [7, 11, 16, 35, 36, 44], + [1, 8, 11, 31, 41, 42], + [13, 14, 16, 38, 42, 45], + [7, 11, 30, 40, 42, 43], + [2, 13, 22, 32, 38, 45], + [1, 3, 5, 14, 22, 45], + ]); + mockQuestions(["8000", "1,2,3,4,5,6", "a", "7"]); + + const app = new App(); + await app.run(); + + expect(MissionUtils.Console.readLineAsync).toHaveBeenCalledTimes(4); + } catch(e) { + expect(() => app.toThrow()); + } + }); }); diff --git a/__tests__/CostTest.js b/__tests__/CostTest.js new file mode 100644 index 000000000..6dff713e5 --- /dev/null +++ b/__tests__/CostTest.js @@ -0,0 +1,49 @@ +import { Console } from '@woowacourse/mission-utils'; + +import Cost from '../src/Cost'; + +const mockCost = (input) => { + Console.readLineAsync = jest.fn(); + + Console.readLineAsync.mockImplementation(() => { + return Promise.resolve(input); + }); +}; + +describe('Cost 클래스 테스트', () => { + test('입력 받은 구입 금액 확인', () => { + const costMessage = '3000'; + mockCost(costMessage); + + const costInstance = new Cost(costMessage); + const { cost } = costInstance; + + expect(cost).toBe(3000); + }); + + test('입력 받은 구입 금액으로부터 추첨 횟수 처리', () => { + const costMessage = '3000'; + mockCost(costMessage); + + const costInstance = new Cost(costMessage); + const { count } = costInstance; + + expect(count).toBe(3); + }); + + test("예외 테스트: 문자열 포함", () => { + const costMessage = '1000j'; + + mockCost(costMessage); + + expect(() => new Cost(costMessage)).toThrow(); + }); + + test("예외 테스트: 추첨 횟수로 딱 나누어지지 않는 경우", () => { + const costMessage = '1100'; + + mockCost(costMessage); + + expect(() => new Cost(costMessage)).toThrow(); + }); +}); diff --git a/__tests__/DrawTest.js b/__tests__/DrawTest.js new file mode 100644 index 000000000..a97f90040 --- /dev/null +++ b/__tests__/DrawTest.js @@ -0,0 +1,107 @@ +import Draw from "../src/Draw.js"; + +describe("Draw 클래스 테스트", () => { + test("당첨 번호와 보너스 번호를 넣으면 값을 얻는다", () => { + const drawnInstance = new Draw({ + tickets: [], + }); + + drawnInstance.setWinning("1,2,3,4,5,6"); + drawnInstance.setBonus("7"); + + drawnInstance.draw(); + + const { drawnNumber: { winning, bonus } } = drawnInstance; + + expect(winning).toEqual([1, 2, 3, 4, 5, 6]); + expect(bonus).toBe(7); + }); + + test("당첨 결과를 뽑는다", () => { + const drawnInstance = new Draw({ + tickets: [ + [1, 2, 3, 14, 15, 16], + [1, 2, 3, 4, 5, 9], + [1, 2, 3, 4, 5, 7], + [1, 22, 33, 44, 15, 19], + ], + }); + drawnInstance.setWinning("1,2,3,4,5,6"); + drawnInstance.setBonus("7"); + + drawnInstance.draw(); + + const { winningResults } = drawnInstance; + + expect(winningResults).toEqual([1, 0, 1, 1, 0]); + }); + + test("뽑은 로또 번호를 set 한다", () => { + const drawnInstance = new Draw({ + tickets: [[1, 2, 3, 4, 5, 6]], + }); + + drawnInstance.setWinning("1,2,3,4,5,6"); + drawnInstance.setBonus("7"); + + drawnInstance.draw(); + + const { tickets } = drawnInstance; + + expect(tickets).toEqual([ + [1, 2, 3, 4, 5, 6], + ]); + }); + + test("예외 테스트: wiinningNumbers - 로또 번호는 숫자여야 합니다.", () => { + expect(() => { + const drawnInstance = new Draw({ + tickets: [[1, 2, 3, 4, 5, 6]], + }); + + drawnInstance.setWinning("1,2,3,4,5,a"); + drawnInstance.setBonus("7"); + + drawnInstance.draw(); + }).toThrow(); + }); + + test("예외 테스트: wiinningNumbers - 로또 번호는 6개여야 합니다.", () => { + expect(() => { + const drawnInstance = new Draw({ + tickets: [[1, 2, 3, 4, 5, 6]], + }); + + drawnInstance.setWinning("1,2,3,4,5,6,7"); + drawnInstance.setBonus("7"); + + drawnInstance.draw(); + }).toThrow(); + }); + + test("예외 테스트: wiinningNumbers - 로또 번호는 1과 45 사이의 숫자여야 합니다.", () => { + expect(() => { + const drawnInstance = new Draw({ + tickets: [[1, 2, 3, 4, 5, 6]], + }); + + drawnInstance.setWinning("1,2,3,4,5,60"); + drawnInstance.setBonus("7"); + + drawnInstance.draw(); + }).toThrow(); + }); + + test("예외 테스트: bonusNumbers - 보너스 번호는 숫자여야 합니다.", () => { + expect(() => { + const drawnInstance = new Draw({ + tickets: [[1, 2, 3, 4, 5, 6]], + }); + + drawnInstance.setWinning("1,2,3,4,5,60"); + drawnInstance.setBonus("a"); + + drawnInstance.draw(); + }).toThrow(); + }); +}); diff --git a/__tests__/LottosTest.js b/__tests__/LottosTest.js new file mode 100644 index 000000000..2cc24a9ee --- /dev/null +++ b/__tests__/LottosTest.js @@ -0,0 +1,34 @@ +import { Random } from '@woowacourse/mission-utils'; + +import Lottos from "../src/Lottos"; + +const mockRandoms = (numbers) => { + Random.pickUniqueNumbersInRange = jest.fn(); + numbers.reduce((acc, number) => { + return acc.mockReturnValueOnce(number); + }, Random.pickUniqueNumbersInRange); +}; + +describe("Lottos 클래스 테스트", () => { + test("n 번 추천 횟수를 넣으면 로또 번호이 n개 나온다", () => { + const count = 8; + const randoms = [ + [8, 21, 23, 41, 42, 43], + [3, 5, 11, 16, 32, 38], + [7, 11, 16, 35, 36, 44], + [1, 8, 11, 31, 41, 42], + [13, 14, 16, 38, 42, 45], + [7, 11, 30, 40, 42, 43], + [2, 13, 22, 32, 38, 45], + [1, 3, 5, 14, 22, 45], + ]; + + mockRandoms(randoms); + + const lottosInstance = new Lottos(count); + const { numbers } = lottosInstance; + + expect(numbers.length).toBe(8); + expect(numbers).toEqual(randoms); + }); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5d..1996468a1 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,19 @@ +import LottoController from './LottoController.js'; + class App { - async run() {} + lottery; + + constructor(){ + this.lottery = new LottoController(); + } + async run() { + const lottery = this.lottery; + try { + await lottery.start(); + } catch (err) { + await this.run(); + } + } } export default App; diff --git a/src/Cost.js b/src/Cost.js new file mode 100644 index 000000000..6fe2fd63c --- /dev/null +++ b/src/Cost.js @@ -0,0 +1,46 @@ +class Cost { + #cost; + #count; + + constructor(cost){ + cost = this.#validate(cost); + const parsedValue = this.#parse(cost); + + this.#cost = parsedValue.cost; + this.#count = parsedValue.count; + } + + #isNumber(value){ + return !Number.isNaN(Number(value)) + } + + #validate(cost){ + if (!this.#isNumber(cost)) { + throw Error('문자열 포함안됨'); + } + + const remainder = Number(cost) % 1000; + if (remainder) { + throw Error('1000원 단위로 입력 필요'); + } + + return cost; + } + + #parse(cost){ + cost = Number(cost); + const count = cost / 1000; + + return { cost, count }; + } + + get cost() { + return this.#cost; + } + + get count() { + return this.#count; + } +} + +export default Cost; \ No newline at end of file diff --git a/src/Draw.js b/src/Draw.js new file mode 100644 index 000000000..166b4b828 --- /dev/null +++ b/src/Draw.js @@ -0,0 +1,143 @@ +class Draw { + drawnNumber = { + winningNumbers: [], + bonusNumber: null, + }; + + tickets = []; + + places = { + // 5등: 3개 일치 (5,000원) - 0개 + 5: (ticket) => { + const { winning, bonus } = this.drawnNumber; + + const matchCount = ticket.filter((ticktNumber) => winning.includes(ticktNumber)); + + if(matchCount.length === 3) return true; + return false; + }, + // 4등: 4개 일치 (50,000원) - 0개 + 4: (ticket) => { + const { winning, bonus } = this.drawnNumber; + + const matchCount = ticket.filter((ticktNumber) => winning.includes(ticktNumber)); + + if(matchCount.length === 4) return true; + return false; + }, + // 3등: 5개 일치 (1,500,000원) - 0개 + 3: (ticket) => { + const { winning, bonus } = this.drawnNumber; + + const matchCount = ticket.filter((ticktNumber) => winning.includes(ticktNumber)); + const bonusMatched = ticket.includes(bonus); + + if(matchCount.length === 5 && !bonusMatched) return true; + return false; + }, + // 2등: 5개 일치, 보너스 볼 일치 (30,000,000원) - 0개 + 2: (ticket) => { + const { winning, bonus } = this.drawnNumber; + + const matchCount = ticket.filter((ticktNumber) => winning.includes(ticktNumber)); + const bonusMatched = ticket.includes(bonus); + + if(matchCount.length === 5 && bonusMatched) return true; + return false; + }, + // 1등: 6개 일치 (2,000,000,000원) - 0개 + 1: (ticket) => { + const { winning, bonus } = this.drawnNumber; + + const matchCount = ticket.filter((ticktNumber) => winning.includes(ticktNumber)); + + if(matchCount.length === 6) return true; + return false; + }, + } + prizes = { + 5: 5000, 4: 50000, 3: 1500000, 2: 30000000, 1: 2000000000, + }; + + constructor({ tickets }){ + this.tickets = tickets; + } + + #isNumber(value){ + return !Number.isNaN(Number(value)) + } + + #valdateWinningNumbers(winningNumbers) { + const raw = winningNumbers.split(','); + + if (raw.some((v) => !this.#isNumber(v))) { + throw Error('로또 번호는 숫자여야 합니다.'); + } + + if (raw.length !== 6) { + throw Error('로또 번호는 6개여야 합니다.'); + } + + if (!raw.every((v) => Number(v) >= 1 && Number(v) <= 45)) { + throw Error('로또 번호는 1과 45 사이의 숫자여야 합니다.') + } + } + + #parseWinnigNumber(winningNumbers) { + return winningNumbers.split(',') + .map(number => Number(number)); + } + + setWinning(winning) { + this.#valdateWinningNumbers(winning); + this.drawnNumber.winning = this.#parseWinnigNumber(winning) + } + + #valdateBonusNumber(bonusNumber) { + if(!this.#isNumber(bonusNumber)){ + throw Error('보너스 번호는 숫자여야 합니다.'); + } + } + + #parseBonusNumber(bonusNumber) { + return Number(bonusNumber) + } + + setBonus(bonus) { + this.#valdateBonusNumber(bonus); + this.drawnNumber.bonus = this.#parseBonusNumber(bonus) + } + + calculateWinningResults () { + const winningResults = [0, 0, 0, 0, 0]; + + this.tickets.forEach((ticket) => { + winningResults.forEach((_, index) => { + const place = this.places[winningResults.length - index]; + if (!place) return; + if (place(ticket)) return winningResults[index]++; + }); + }); + + this.winningResults = winningResults; + } + + calculateReturnOnInvestment() { + const { prizes } = this; + const { winningResults } = this; + + const prize = winningResults.reduce((acc, result, index) => + acc + (result * prizes[(winningResults.length - index)]) + , 0); + const cost = this.tickets.length * 1000; + + this.returnOnInvestment = prize / cost * 100; + } + + draw() { + this.calculateWinningResults(); + this.calculateReturnOnInvestment(); + } +} + +export default Draw; diff --git a/src/InputView.js b/src/InputView.js new file mode 100644 index 000000000..281af97c7 --- /dev/null +++ b/src/InputView.js @@ -0,0 +1,23 @@ +import { Console } from '@woowacourse/mission-utils'; + +class InputView { + static async readCost() { + const cost = await Console.readLineAsync('구입금액을 입력해 주세요.'); + + return cost; + } + + static async readWinningNumbers() { + const winnerNumbers = await Console.readLineAsync('당첨 번호를 입력해 주세요.'); + + return winnerNumbers; + } + + static async readBonusNumbers() { + const bonusNumbers = await Console.readLineAsync('보너스 번호를 입력해 주세요.'); + + return bonusNumbers; + } +} + +export default InputView; diff --git a/src/Lotto.js b/src/Lotto.js index cb0b1527e..906e2e9f9 100644 --- a/src/Lotto.js +++ b/src/Lotto.js @@ -10,9 +10,15 @@ class Lotto { if (numbers.length !== 6) { throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); } + + if ([...(new Set(numbers))].length !== 6) { + throw new Error("[ERROR] 로또 번호는 중복되지 않는 숫자 6개여야 합니다."); + } } - // TODO: 추가 기능 구현 + get numbers() { + return this.#numbers; + } } export default Lotto; diff --git a/src/LottoController.js b/src/LottoController.js new file mode 100644 index 000000000..b3485c143 --- /dev/null +++ b/src/LottoController.js @@ -0,0 +1,60 @@ +import { Console } from '@woowacourse/mission-utils'; + +import InputView from './InputView.js'; +import OutputView from './OutputView.js'; + +import Cost from './Cost.js'; +import Lottos from './Lottos.js'; +import Draw from './Draw.js'; + +class LottoController { + count; + numbers; + + async start() { + try { + if (!this.count) { + const costMessage = await InputView.readCost(); + const costInstance = new Cost(costMessage); + const { count } = costInstance; + this.count = count; + + OutputView.printCount(count); + } + + if (!this.numbers) { + const lottosInstance = new Lottos(this.count); + const { numbers } = lottosInstance; + this.numbers = numbers; + + OutputView.printNumbers(numbers); + } + + this.draw = this.draw || new Draw({ tickets: this.numbers, }); + + if(!this.draw.drawnNumber.winning){ + const winningNumbersMessage = await InputView.readWinningNumbers(); + this.draw.setWinning(winningNumbersMessage); + } + + if(!this.draw.drawnNumber.bonus){ + const bonusNumberMessage = await InputView.readBonusNumbers(); + this.draw.setBonus(bonusNumberMessage); + } + + this.draw.draw(); + + const { + winningResults, returnOnInvestment, + } = this.draw; + + OutputView.printWinningResults(winningResults); + OutputView.printReturnOnInvestment(returnOnInvestment); + } catch (err) { + Console.print(`[ERROR] ${err}`); + throw err; + } + } +} + +export default LottoController; diff --git a/src/Lottos.js b/src/Lottos.js new file mode 100644 index 000000000..6f8ef1163 --- /dev/null +++ b/src/Lottos.js @@ -0,0 +1,35 @@ +import { Random } from '@woowacourse/mission-utils'; + +import Lotto from './Lotto.js'; + +class Lottos { + #lottos = []; + + constructor(count){ + Array.from({ length: count }).forEach(() => { + this.addLotto(); + }); + } + + get lottos(){ + return this.#lottos; + } + + + get numbers(){ + const numbers = this.#lottos.map(lotto => lotto.numbers); + + return numbers; + } + + addLotto(){ + const lottoNumbers = this.getRandomLottoNumber(); + this.#lottos = [...this.#lottos, new Lotto(lottoNumbers)]; + } + + getRandomLottoNumber(){ + return Random.pickUniqueNumbersInRange(1, 45, 6); + } +} + +export default Lottos; \ No newline at end of file diff --git a/src/OutputView.js b/src/OutputView.js new file mode 100644 index 000000000..64c032143 --- /dev/null +++ b/src/OutputView.js @@ -0,0 +1,37 @@ +import { Console } from '@woowacourse/mission-utils'; + +class OutputView { + static printCount(count) { + Console.print(`${count}개를 구매했습니다.`); + } + + static printNumbers(numbers) { + numbers + .map((nums) => `[${nums.join(', ')}]`) + .forEach((item) => { + Console.print(item); + }); + } + + static printWinningResults(winningResults) { + const winningResultMessages = [ + (winningResult) => `3개 일치 (5,000원) - ${winningResult}개`, + (winningResult) => `4개 일치 (50,000원) - ${winningResult}개`, + (winningResult) => `5개 일치 (1,500,000원) - ${winningResult}개`, + (winningResult) => `5개 일치, 보너스 볼 일치 (30,000,000원) - ${winningResult}개`, + (winningResult) => `6개 일치 (2,000,000,000원) - ${winningResult}개`, + ]; + + winningResults + .map((winningResult, index) => winningResultMessages[index](winningResult)) + .forEach((item) => { + Console.print(item); + }); + } + + static printReturnOnInvestment(returnOnInvestment) { + Console.print(`총 수익률은 ${returnOnInvestment}%입니다.`); + } +} + +export default OutputView;