diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js index 872380c9c..9c158e048 100644 --- a/__tests__/ApplicationTest.js +++ b/__tests__/ApplicationTest.js @@ -1,6 +1,7 @@ import App from "../src/App.js"; import { MissionUtils } from "@woowacourse/mission-utils"; +// 입력값을 미리 설정하기 위해 MissionUtils.Console.readLineAsync 를 모킹 ( 제공된 입력값들을 순차적으로 반환하도록 설정) const mockQuestions = (inputs) => { MissionUtils.Console.readLineAsync = jest.fn(); @@ -11,19 +12,21 @@ const mockQuestions = (inputs) => { }); }; +// 로또 번호를 미리 설정하기 위해 MissionUtils.Random.pickUniqueNumbersInRange 메서드를 모킹 const mockRandoms = (numbers) => { MissionUtils.Random.pickUniqueNumbersInRange = jest.fn(); numbers.reduce((acc, number) => { return acc.mockReturnValueOnce(number); }, MissionUtils.Random.pickUniqueNumbersInRange); }; - +// MissionUtils.Console.print메서드를 감시하는 logSpy 객체를 만든다. 실제 출력된 로그를 확인하고 검증할 수 있다. const getLogSpy = () => { const logSpy = jest.spyOn(MissionUtils.Console, "print"); logSpy.mockClear(); return logSpy; }; +// 특정 입력값을 주고 실행한 후 오류 메시지를 검증하는 함수 const runException = async (input) => { // given const logSpy = getLogSpy(); diff --git a/src/App.js b/src/App.js index 091aa0a5d..08f27dc3a 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,12 @@ +import LottoGame from "./LottoGame.js"; +// 프로그램의 진입점이자 전체 흐름을 관리하는 역할 +// 비동기 작업을 수행하기 위한 메서드 +// run() 메서드는 프로그램의 메인 진입점. 이 메서드에서 로또 게임의 각 단계를 비동기로 처리한다. class App { - async run() {} + async run() { + const game = new LottoGame(); + await game.start(); + } } export default App; diff --git a/src/ConsoleHandler.js b/src/ConsoleHandler.js new file mode 100644 index 000000000..025e19a1b --- /dev/null +++ b/src/ConsoleHandler.js @@ -0,0 +1,13 @@ +import { MissionUtils } from "@woowacourse/mission-utils"; +//사용자 입출력 처리 모듈 +class ConsoleHandler { + static async readLineAsync(prompt) { + return await MissionUtils.Console.readLineAsync(prompt); + } + + static print(message) { + MissionUtils.Console.print(message); + } +} + +export default ConsoleHandler; \ No newline at end of file diff --git a/src/Constants.js b/src/Constants.js new file mode 100644 index 000000000..9b90efef1 --- /dev/null +++ b/src/Constants.js @@ -0,0 +1,9 @@ +// 상금, 로또 가격 등의 상수를 관리한다. +export const LOTTO_PRICE = 1000; +export const PRIZE_TABLE = { + 1: 2000000000, + 2: 30000000, + 3: 1500000, + 4: 50000, + 5: 5000, +} \ No newline at end of file diff --git a/src/Lotto.js b/src/Lotto.js index cb0b1527e..3a2434a3b 100644 --- a/src/Lotto.js +++ b/src/Lotto.js @@ -1,18 +1,33 @@ +// Lotto 클래스를 사용하여 로또 번호를 관리하는 클래스 class Lotto { - #numbers; + #numbers; // 프라이빗 필드로 numbers 선언 constructor(numbers) { - this.#validate(numbers); - this.#numbers = numbers; + this.#validate(numbers); // 유효성 검사 + this.#numbers = numbers; // 유효한 경우에만 필드에 할당 } + // numbers 배열이 유효한지 확인하는 메서드 #validate(numbers) { + if (!Array.isArray(numbers)) { + throw new Error("[ERROR] 로또 번호는 배열이어야 합니다."); + } if (numbers.length !== 6) { - throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); + throw new Error("[ERROR] 로또 번호는 6개의 숫자여야 합니다."); + } + if (!numbers.every(num => typeof num === 'number' && num >= 1 && num <= 45)) { + throw new Error("[ERROR] 로또 번호는 1 ~ 45 사이의 숫자여야 합니다."); + } + if (new Set(numbers).size !== numbers.length) { + throw new Error("[ERROR] 로또 번호는 중복되지 않는 숫자여야 합니다."); } } - - // TODO: 추가 기능 구현 + + // numbers 필드에 저장된 로또 번호를 반환하는 메서드 + getNumbers() { + return [...this.#numbers]; // numbers 배열의 복사본을 반환하여 외부에서의 변경을 방지 + } } +// Lotto 클래스를 외부에서 사용할 수 있도록 export export default Lotto; diff --git a/src/LottoGame.js b/src/LottoGame.js new file mode 100644 index 000000000..bae8b7ab6 --- /dev/null +++ b/src/LottoGame.js @@ -0,0 +1,117 @@ +import { MissionUtils } from "@woowacourse/mission-utils"; +import Lotto from "./Lotto.js"; +import LottoResult from "./LottoResult.js"; +import LottoMachine from "./LottoMachine.js" +import { LOTTO_PRICE } from "./Constants.js"; + +// 로또 게임의 메인 흐름을 관리 +class LottoGame { + // 로또 게임의 초기화 작업 + constructor(){ + this.lottoMachine = new LottoMachine(); //로또 번호 생성기(인스턴스 생성) + this.lottoResult = new LottoResult(); //결과 계산기 + } + // 비동기 작업을 수행하기 위한 메서드 + async start(){ + try + { + const amount = await this.inputPurchaseAmount(); //구입 금액 입력 및 검증 + + // 로또 발행 + const purchasedLottos = this.purchaseLottos(amount); + + this.printPurchasedLottos(purchasedLottos); + + // 당첨 번호 및 보너스 번호 입력 + const { winningNumbers, bonusNumber } = await this.inputWinningNumbers(); + + // 당첨 결과 계산 + this.lottoResult.calculateRank(purchasedLottos, winningNumbers, bonusNumber); + + // 당첨 결과 및 통계 출력 + this.printLottoStatistics(this.lottoResult.getStatistics(), amount); + + } catch (error) + { + MissionUtils.Console.print(error.message); + } + } + //구입 금액 입려받고 유효성 검사. + async inputPurchaseAmount(){ + const amountStr = await MissionUtils.Console.readLineAsync("구입 금액을 입력해 주세요: "); + const amount = parseInt(amountStr, 10); //문자열을 정수로 변환 + + if(isNaN(amount) || amount % LOTTO_PRICE !== 0) { + throw new Error("[ERROR] 로또 구입 금액은 1000원 단위여야 합니다."); + } + return amount; + } + + purchaseLottos(amount){ //구매한 로또 배열 반환 + const lottoCount = amount / LOTTO_PRICE; + + const lottos = []; // 스택에 저장 + + for(let i = 0; i < lottoCount; i++){ + const numbers = this.lottoMachine.generateLotto(); // 로또 번호 생성 + lottos.push(numbers); + } + + return lottos; + } + + //당첨 번호 입력받기 + async inputWinningNumbers() { + //사용자 입력 변수 선언 + const WinningNumbersStr = await MissionUtils.Console.readLineAsync("당첨 번호를 입력해 주세요. "); + const bonusNumberStr = await MissionUtils.Console.readLineAsync("보너스 번호를 입력해 주세요. "); + + const winningNumbers = WinningNumbersStr.split(",").map(Number); // , 단위로 나눈 뒤 매핑 + const bonusNumber = parseInt(bonusNumberStr, 10); //정수로만 바꾸기 + + this.validateWinningNumbers(winningNumbers, bonusNumber); // 유효성 검사 + + return { winningNumbers, bonusNumber }; + } + + // 순회하면 유효성 검사 + validateWinningNumbers(winningNumbers, bonusNumber){ + if (winningNumbers.length !== 6 || new Set(winningNumbers).size !== 6){ + throw new Error("[ERROR] 당첨 번호는 1부터 45 사이의 중복되지 않는 숫자 6개여야 합니다."); + } + if (!this.isValidLottoNumber(bonusNumber)){ + throw new Error("[ERROR] 보너스 번호는 1부터 45 사이의 숫자여야 합니다."); + } + winningNumbers.forEach(num =>{ + if(!this.isValidLottoNumber(num)){ + throw new Error("[ERROR] 당첨 번호는 1부터 45 사이의 숫자여야 합니다. "); + } + }); + } + + isValidLottoNumber(num) { + return num >= 1 && num <= 45; + } + + printPurchasedLottos(lottos) { + MissionUtils.Console.print(`${lottos.length}개를 구매했습니다.`); + lottos.forEach(lotto => { + + MissionUtils.Console.print(`[${lotto.sort((a,b) => a - b).join(", ")}]`); // 오름차순으로 출력된다. + }); + } + + printLottoStatistics(statistics, purchaseAmount) { + MissionUtils.Console.print("당첨 통계"); + MissionUtils.Console.print("-----------"); + + Object.entries(statistics.rankCounts).forEach(([rank,count]) => { + MissionUtils.Console.print(`${rank}등: ${count}개`); + }); + + const profitRate = this.lottoResult.calculateProfit(purchaseAmount); + MissionUtils.Console.print(`총 수익률은 ${profitRate}%입니다.`); + } +} + +export default LottoGame; diff --git a/src/LottoMachine.js b/src/LottoMachine.js new file mode 100644 index 000000000..22b7cba6f --- /dev/null +++ b/src/LottoMachine.js @@ -0,0 +1,18 @@ +import { MissionUtils } from "@woowacourse/mission-utils"; +import Lotto from "./Lotto.js"; +// 로또 티켓과 당첨 번호를 생성하는 역할 + +class LottoMachine { + generateLotto() { + const numbers = MissionUtils.Random.pickUniqueNumbersInRange(1, 45, 6); + + return numbers; + } + + generateWinningNumbers(){ + const winningNumbers = MissionUtils.Random.pickUniqueNumbersInRange(1,45,6); + const bonusNumber = MissionUtils.Random.pickUniqueNumbersInRange(1,45,1)[0]; + return {winningNumbers, bonusNumber}; + } +} +export default LottoMachine; \ No newline at end of file diff --git a/src/LottoResult.js b/src/LottoResult.js new file mode 100644 index 000000000..f9f334ade --- /dev/null +++ b/src/LottoResult.js @@ -0,0 +1,60 @@ +// 당첨 여부를 계산하는 역할 +import { PRIZE_TABLE } from "./Constants.js"; + +class LottoResult { + constructor() { + this.rankCounts = { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }; + this.totalProfit = 0; + } + + calculateRank(purchasedLottos, winningNumbers, bonusNumber){ + purchasedLottos.forEach(lotto => { + const matchCount = this.countMatchingNumbers(lotto, winningNumbers); //matchcount + const isBonusMatched = lotto.includes(bonusNumber); //isBonusMatched + + const rank = this.getRank(matchCount, isBonusMatched); + if(rank) { + this.rankCounts[rank] += 1; + this.totalProfit += this.getPrize(rank); + } + }); + } + + countMatchingNumbers(lottoNumbers, winningNumbers) { + return lottoNumbers.filter(num => winningNumbers.includes(num)).length; //includes() 포함되어 있는 지 확인 + } // 매칭된 수 반환 + + getRank(matchCount,isBonusMatched){ + if(matchCount === 6) return 1; + if(matchCount === 5 && isBonusMatched) return 2; + if(matchCount === 5) return 3; + if(matchCount === 4) return 4; + if(matchCount === 3) return 5; + return null; + } + + getPrize(rank) { + return PRIZE_TABLE[rank] || 0; + } + + calculateProfit(purchaseAmount) { + const profitRate = (this.totalProfit / purchaseAmount) * 100; + return profitRate.toFixed(1); // 소수점 1자리까지 반올림 + } + + getStatistics(){ + return { + rankCounts: this.rankCounts, + totalProfit: this.totalProfit, + }; + } + +} + +export default LottoResult; \ No newline at end of file