diff --git a/README.md b/README.md index 15bb106b5..77d59c1de 100644 --- a/README.md +++ b/README.md @@ -1 +1,79 @@ # javascript-lotto-precourse + +로또 + +## 프로젝트 개요 +콘솔(Console) 환경에서 동작하는 로또 게임 애플리케이션입니다. 사용자가 구입 금액을 입력하면 로또를 발급하고, +당첨 번호와 보너스 번호를 입력받아 당첨 통계 및 수익률을 계산합니다. 사용자는 구매 금액을 입력하면 자동으로 로또 번호가 발행됩니다. +이후 당첨 번호와 보너스 번호를 입력하면, 전체 당첨 통계와 총 수익률이 계산되어 출력됩니다. + +- 목표: 로또 발매기 및 여러 에러 케이스 정리 +- 패턴: MVC (Model-View-Controller) + +## 기능적 요소 +| 기능 | 설명 | +| ------------------------ | ----------------------------------- | +| **구매 금액 입력 및 유효성 검사** | 1,000원 단위 금액만 허용. 0 이하 금액 예외 처리 | +| **로또 자동 발행** | 1~45 사이의 숫자 6개 랜덤 생성 (중복 없음) | +| **당첨 결과 계산** | 3개 이상 일치 시 당첨. 5개+보너스 시 2등 처리 | +| **통계 및 수익률 계산** | 전체 구매 대비 수익률 (%) 계산 | +| **에러 및 입력 검증 시스템**| 통합 예외 처리 | +| **자동 시뮬레이션** | 1만 장 구매 등 대규모 통계 테스트 가능 | + + +## 기능 요구 사항 +주요 설계 결정 +1. MVC (Model-View-Controller) 패턴 적용 +- Model (Lotto, LottoStore, PrizeCalculator): 데이터와 비즈니스 로직을 담당합니다. +- View (InputView, OutputView): 콘솔 입출력(UI)을 담당하며, 로직을 가지지 않습니다. +- Controller (LottoController): View로부터 입력을 받아 Model을 제어하고, Model의 데이터를 View를 통해 출력합니다. +- 이를 통해 **관심사 분리(Separation of Concerns)**를 달성하여 코드의 유지보수성과 테스트 용이성을 높였습니다. + +2. 도메인 객체의 자가 유효성 검증 (Self-Validation) +- Lotto 클래스는 생성자(constructor)에서 로또 번호의 개수(6개), 중복, 숫자 범위(1~45)를 스스로 검증합니다. +- 따라서 LottoController는 당첨 번호를 입력받을 때도 new Lotto(numbers)를 호출하는 것만으로 검증 로직을 재사용 가능. + +3. 상수 설정 +- LottoConfig.js 파일에 로또 가격, 번호 범위, 당첨금, 모든 에러 메시지를 상수로 정의 +- 로또 가격 변경하거나 "에러 메시지 수정"이 필요할 때, 이 파일 한 곳만 수정하면 전체 반영. + +## 아키텍쳐 +``` +MVC 패턴 +📁 src + ├── App.js # 프로그램 시작점 (run()) + ├── controller/ + │ └── LottoController.js # 사용자 입력/출력 흐름 제어 + ├── model/ + │ ├── Lotto.js # 한 장 로또, 번호 관리 + │ ├── LottoStore.js # 여러 장 로또 관리 + │ └── PrizeCalculator.js # 당첨 결과 계산 및 수익률 + ├── view/ + │ ├── InputView.js # 사용자 입력 + │ └── OutputView.js # 출력 + └── constants/ + └── LottoConfig.js # 번호 범위, 가격, 상금, 등수 설정 +``` + +## branch 구조 +| 브랜치 이름 | 담당 기능 | 상세 내용 | 테스트 포인트 | +| ---------------------------- | ------------------ | --------------------------------------------------------- | --------------------------------------------- | +| `feature/set-up` | 기본 구조 생성 | MVC 패턴 별 폴더, 파일 생성 | 기본적인 프로젝트 생성 | +| `feature/purchase-input` | 구입 금액 입력 | 1000원 단위 입력, 잘못된 입력 시 `[ERROR]` 출력 후 재입력 | 금액 범위, 1000원 단위 확인, 재입력 흐름 | +| `feature/lotto-generation` | 로또 발행 | 1~45 범위, 중복 없는 6개 번호 생성, N장 구매 시 N개 생성 | 번호 개수, 범위, 중복, 오름차순 정렬 | +| `feature/winning-input` | 당첨 번호 입력 | 쉼표 구분 6개 숫자 입력, 범위 1~45, 중복 불가 | 번호 개수, 범위, 중복, 재입력 흐름 | +| `feature/result-calculation` | 당첨 결과 & 수익률 | 등수 판정, 당첨 개수 계산, 총 수익률 계산 | 등수 판정 로직, 보너스 번호 판정, 수익률 계산 | +| `feature/error-handling` | 예외 처리 | 금액, 번호, 보너스 입력 오류 처리, `[ERROR]` 메시지 통일 | Error 메시지 테스트, 재입력 흐름 검증 | + +## 코드적 요소 +| 요소 | 상세 내용 | +| ------------- | ---------------------------------------------------------- | +| 객체지향 | Lotto, LottoStore, PrizeCalculator 클래스별 단일 책임(SRP) | +| SRP | 클래스/메서드별 단일 책임 유지, 15줄 이하 | +| 에러 처리 | `[ERROR]` 메시지 일관성, 타입별 Error 분리 가능 | +| 입출력 추상화 | InputView / OutputView 인터페이스 적용 → Mocking 가능 | +| 설정화 | LottoConfig → 번호 범위, 가격, 상금, 등수 관리 | + +요구사항: SRP, 함수 길이 15줄 이하, 3항 연산자 사용 금지, 함수형 프로그래밍 일부 적용 + +👤 개발자 이름: 이원형 프리코스 과제: 자동차 경주 (racingcar-precourse) \ No newline at end of file diff --git a/__tests__/LottoSimulation.test.js b/__tests__/LottoSimulation.test.js new file mode 100644 index 000000000..63b7d1fde --- /dev/null +++ b/__tests__/LottoSimulation.test.js @@ -0,0 +1,110 @@ +import LottoStore from '../src/model/LottoStore.js'; +import PrizeCalculator from '../src/model/PrizeCalculator.js'; +import OutputView from '../src/view/OutputView.js'; +import Lotto from '../src/Lotto.js'; +import { LOTTO_CONFIG } from '../src/LottoConfig.js'; +import { Random, Console } from '@woowacourse/mission-utils'; + +// Console만 모킹하고, Random은 실제 구현을 사용하도록 설정 +jest.mock('@woowacourse/mission-utils', () => ({ + Random: jest.requireActual('@woowacourse/mission-utils').Random, + // Console.print만 console.log로 연결하여 출력이 보이게 + Console: { + print: jest.fn(console.log), + readLineAsync: jest.fn(), + }, +})); + +describe('🧪 Lotto Simulation (1만 장 통계 테스트)', () => { + // --- 3. 기존 simulation.js의 헬퍼 함수들을 테스트 스위트 내부에 정의 --- + + /** + 당첨 번호 6개를 무작위로 생성하고 정렬 + @returns {number[]} - 정렬된 당첨 번호 + */ + const generateWinningNumbers = () => { + const numbers = Random.pickUniqueNumbersInRange( + LOTTO_CONFIG.MIN_NUMBER, + LOTTO_CONFIG.MAX_NUMBER, + LOTTO_CONFIG.NUMBER_COUNT, + ); + // Lotto 모델 번호 정렬 + const lotto = new Lotto(numbers); + return lotto.getNumbers(); + }; + + /** + 당첨 번호와 겹치지 않는 보너스 번호 1개를 무작위로 생성 + @param {number[]} winningNumbers - 당첨 번호 배열 + @returns {number} - 보너스 번호 + */ + const generateBonusNumber = (winningNumbers) => { + while (true) { + const number = Random.pickNumberInRange( + LOTTO_CONFIG.MIN_NUMBER, + LOTTO_CONFIG.MAX_NUMBER, + ); + if (!winningNumbers.includes(number)) { + return number; + } + } + }; + + const printSimulationHeader = (count, amount, winning, bonus) => { + Console.print('--- 🧪 자동 시뮬레이션 결과 ---'); + Console.print(`[시뮬레이션 조건]`); + Console.print(`- 구매 개수: ${count.toLocaleString()}개`); + Console.print(`- 총 구매액: ${amount.toLocaleString()}원`); + Console.print(`- (자동 생성) 당첨 번호: [${winning.join(', ')}]`); + Console.print(`- (자동 생성) 보너스 번호: ${bonus}`); + }; + + // 각 테스트 전에 print 호출 기록 초기화 + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('1만 장 구매 시뮬레이션이 실행되고 통계가 콘솔에 출력되어야 한다.', () => { + // 시뮬레이션 로직을 테스트 케이스 내에서 직접 실행 + + // 시뮬레이션 설정 + const SIMULATION_COUNT = 10_000; + const PURCHASE_AMOUNT = SIMULATION_COUNT * LOTTO_CONFIG.PRICE_PER_TICKET; + + // 1. 로또 대량 구매 + const lottoStore = new LottoStore(); + lottoStore.generateLottos(SIMULATION_COUNT); + const lottos = lottoStore.getLottos(); + + // 2. 당첨/보너스 번호 생성 + const winningNumbers = generateWinningNumbers(); + const bonusNumber = generateBonusNumber(winningNumbers); + + // 3. 당첨 결과 계산 + const prizeCalculator = new PrizeCalculator(); + const results = prizeCalculator.calculateResults( + lottos, + winningNumbers, + bonusNumber, + ); + const totalPrize = prizeCalculator.calculateTotalPrize(results); + const rateOfReturn = prizeCalculator.calculateRateOfReturn( + totalPrize, + PURCHASE_AMOUNT, + ); + + // 4. 시뮬레이션 결과 출력 + printSimulationHeader( + SIMULATION_COUNT, + PURCHASE_AMOUNT, + winningNumbers, + bonusNumber, + ); + OutputView.printResults(results, rateOfReturn); + + // 5. 테스트 검증 + expect(Console.print).toHaveBeenCalled(); + const lastCall = Console.print.mock.calls[Console.print.mock.calls.length - 1]; + expect(lastCall[0]).toEqual(expect.stringContaining('총 수익률은')); + }); +}); \ No newline at end of file diff --git a/__tests__/PrizeCalculator.test.js b/__tests__/PrizeCalculator.test.js new file mode 100644 index 000000000..714a26253 --- /dev/null +++ b/__tests__/PrizeCalculator.test.js @@ -0,0 +1,55 @@ +import Lotto from '../src/Lotto.js'; +import PrizeCalculator from '../src/model/PrizeCalculator.js'; +import { RANK, LOTTO_CONFIG } from '../src/LottoConfig.js'; + +describe('PrizeCalculator 테스트', () => { + let prizeCalculator; + + beforeEach(() => { + prizeCalculator = new PrizeCalculator(); + }); + + const winningNumbers = [1, 2, 3, 4, 5, 6]; + const bonusNumber = 7; + + // 1. 당첨 결과 (calculateResults) 테스트 + test.each([ + // [설명, 로또번호, 기대 등수] + ['1등 (6개 일치)', new Lotto([1, 2, 3, 4, 5, 6]), RANK.FIRST], + ['2등 (5개 + 보너스)', new Lotto([1, 2, 3, 4, 5, 7]), RANK.SECOND], + ['3등 (5개 일치)', new Lotto([1, 2, 3, 4, 5, 8]), RANK.THIRD], + ['4등 (4개 일치)', new Lotto([1, 2, 3, 4, 8, 9]), RANK.FOURTH], + ['5등 (3개 일치)', new Lotto([1, 2, 3, 8, 9, 10]), RANK.FIFTH], + ['낙첨 (2개 일치)', new Lotto([1, 2, 8, 9, 10, 11]), null], + ])('%s 테스트', (desc, lotto, expectedRank) => { + const lottos = [lotto]; + const results = prizeCalculator.calculateResults(lottos, winningNumbers, bonusNumber); + + // 기대 등수가 null이 아니면, 해당 등수가 1개여야 함 + if (expectedRank) { + expect(results.get(expectedRank)).toBe(1); + } + + // 5등부터 1등까지 총합이 1 또는 0 (낙첨) 이어야 함 + const totalWins = Array.from(results.values()).reduce((a, b) => a + b, 0); + expect(totalWins).toBe(expectedRank ? 1 : 0); + }); + + // 2. 수익률 (calculateRateOfReturn) 테스트 + test('총 수익률을 소수점 둘째 자리에서 반올림하여 계산한다 (예: 62.5%)', () => { + // 8000원 구매, 5000원(5등) 당첨 + const purchaseAmount = 8000; + const totalPrize = 5000; + const rate = prizeCalculator.calculateRateOfReturn(totalPrize, purchaseAmount); + + // (5000 / 8000) * 100 = 62.5 + expect(rate).toBe(62.5); + }); + + test('수익률 계산 시 100.0%인 경우', () => { + const purchaseAmount = 1000; + const totalPrize = 1000; + const rate = prizeCalculator.calculateRateOfReturn(totalPrize, purchaseAmount); + expect(rate).toBe(100.0); + }); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5d..f6ee0f534 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,30 @@ +// src/App.js + +import LottoController from './controller/LottoController.js'; + class App { - async run() {} + #lottoController; + + constructor() { + this.#lottoController = new LottoController(); + } + + async run() { + // 1. 구입 금액 입력 및 로또 개수 반환 + const count = await this.#lottoController.getPurchaseAmount(); + + // 2. 로또 발행 및 출력 + this.#lottoController.issueLottos(count); + + // 3. 당첨 번호 입력 + const winningNumbers = await this.#lottoController.getWinningNumbers(); + + // 4. 보너스 번호 입력 (당첨 번호와 중복 검사를 위해 winningNumbers 전달) + const bonusNumber = await this.#lottoController.getBonusNumber(winningNumbers); + + // 5. 결과 계산 및 출력 + this.#lottoController.calculateAndShowResults(winningNumbers, bonusNumber); + } } -export default App; +export default App; \ No newline at end of file diff --git a/src/Lotto.js b/src/Lotto.js index cb0b1527e..eb48acd18 100644 --- a/src/Lotto.js +++ b/src/Lotto.js @@ -1,18 +1,74 @@ +import { LOTTO_CONFIG, ERROR_MESSAGES } from './LottoConfig.js'; + class Lotto { #numbers; constructor(numbers) { + // 기본 검증 this.#validate(numbers); - this.#numbers = numbers; + // 유효성 검사 통과 시, 오름차순으로 정렬 저장 + this.#numbers = numbers.sort((a, b) => a - b); } #validate(numbers) { - if (numbers.length !== 6) { - throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); + // 1. 기본 검증 + if (numbers.length !== LOTTO_CONFIG.NUMBER_COUNT) { + throw new Error(ERROR_MESSAGES.LOTTO_LENGTH); + } + + // 2.중복 검증 + if (new Set(numbers).size !== LOTTO_CONFIG.NUMBER_COUNT) { + throw new Error(ERROR_MESSAGES.LOTTO_DUPLICATE); + } + + // 3. 개별 번호의 범위 및 타입 검증 + numbers.forEach((number) => { + this.#validateNumber(number); + }); + } + + // 개별 숫자를 검증하는 private 메서드 + #validateNumber(number) { + if (number < LOTTO_CONFIG.MIN_NUMBER || number > LOTTO_CONFIG.MAX_NUMBER) { + throw new Error(ERROR_MESSAGES.LOTTO_RANGE); + } + if (!Number.isInteger(number)) { + throw new Error(ERROR_MESSAGES.LOTTO_NOT_INTEGER); } } - // TODO: 추가 기능 구현 + //[추가] 로또 번호를 외부(View, Calculator)에서 읽을 수 있도록 getter 제공 + + /** + @returns {number[]} - 정렬된 로또 번호 + */ + getNumbers() { + return this.#numbers; + } + + /** + //당첨 번호와 몇 개가 일치하는지 계산 (결과 계산 시 필요) + @param {number[]} winningNumbers - 당첨 번호 6개 + @returns {number} - 일치하는 번호 개수 + */ + countMatch(winningNumbers) { + const winningSet = new Set(winningNumbers); + + const matchCount = this.#numbers.filter((number) => + winningSet.has(number) + ).length; + + return matchCount; + } + + /** + // 보너스 번호를 포함하는지 확인 (결과 계산 시 필요) + @param {number} bonusNumber - 보너스 번호 + @returns {boolean} - 포함 여부 + */ + hasBonus(bonusNumber) { + return this.#numbers.includes(bonusNumber); + } } -export default Lotto; +export default Lotto; \ No newline at end of file diff --git a/src/LottoConfig.js b/src/LottoConfig.js new file mode 100644 index 000000000..ecd9417b4 --- /dev/null +++ b/src/LottoConfig.js @@ -0,0 +1,48 @@ +export const LOTTO_CONFIG = { + MIN_NUMBER: 1, + MAX_NUMBER: 45, + NUMBER_COUNT: 6, + PRICE_PER_TICKET: 1000, +}; + +// 당첨 등수 식별자 +export const RANK = { + FIRST: 'FIRST', + SECOND: 'SECOND', + THIRD: 'THIRD', + FOURTH: 'FOURTH', + FIFTH: 'FIFTH', +}; + +// 등수별 당첨금 +export const PRIZE_MONEY = { + [RANK.FIRST]: 2_000_000_000, + [RANK.SECOND]: 30_000_000, + [RANK.THIRD]: 1_500_000, + [RANK.FOURTH]: 50_000, + [RANK.FIFTH]: 5_000, +}; + + +// 에러 메시지 정리 +export const ERROR_MESSAGES = { + // 구입 금액 관련 에러 + AMOUNT_NAN: '[ERROR] 구입 금액은 숫자여야 합니다.', + AMOUNT_NEGATIVE: '[ERROR] 구입 금액은 0보다 커야 합니다.', + AMOUNT_UNIT: `[ERROR] 금액은 ${LOTTO_CONFIG.PRICE_PER_TICKET}원 단위여야 합니다.`, + + // 로또 번호 관련 에러 (Lotto.js에서 사용) + LOTTO_LENGTH: `[ERROR] 로또 번호는 ${LOTTO_CONFIG.NUMBER_COUNT}개여야 합니다.`, + LOTTO_DUPLICATE: '[ERROR] 로또 번호에 중복된 숫자가 있습니다.', + LOTTO_RANGE: `[ERROR] 로또 번호는 ${LOTTO_CONFIG.MIN_NUMBER}부터 ${LOTTO_CONFIG.MAX_NUMBER} 사이의 숫자여야 합니다.`, + LOTTO_NOT_INTEGER: '[ERROR] 로또 번호는 정수여야 합니다.', + + + // 당첨 번호 파싱 관련 (쉼표로 구분되지 않거나 숫자가 아닌 경우) + WINNING_NOT_NUMBER: '[ERROR] 당첨 번호는 쉼표로 구분된 숫자여야 합니다.', + + // 보너스 번호 관련 에러 + BONUS_NAN: '[ERROR] 보너스 번호는 숫자여야 합니다.', + BONUS_RANGE: `[ERROR] 보너스 번호는 ${LOTTO_CONFIG.MIN_NUMBER}부터 ${LOTTO_CONFIG.MAX_NUMBER} 사이의 숫자여야 합니다.`, + BONUS_DUPLICATE: '[ERROR] 보너스 번호는 당첨 번호와 중복될 수 없습니다.', +}; \ No newline at end of file diff --git a/src/controller/LottoController.js b/src/controller/LottoController.js new file mode 100644 index 000000000..0fa311de7 --- /dev/null +++ b/src/controller/LottoController.js @@ -0,0 +1,180 @@ +import InputView from '../view/InputView.js'; +import OutputView from '../view/OutputView.js'; +import LottoStore from '../model/LottoStore.js'; +import { LOTTO_CONFIG, ERROR_MESSAGES } from '../LottoConfig.js'; +import PrizeCalculator from '../model/PrizeCalculator.js'; +import Lotto from "../Lotto.js" + +class LottoController { + #lottoStore; + #prizeCalculator; + #purchaseAmount; + + constructor() { + this.#lottoStore = new LottoStore(); + this.#prizeCalculator = new PrizeCalculator(); + } + + async getPurchaseAmount() { + while (true) { + try { + const input = await InputView.readPurchaseAmount(); + const amount = this.#validateAmount(input); + const count = amount / LOTTO_CONFIG.PRICE_PER_TICKET; + + // 구매 금액 저장 + this.#purchaseAmount = amount; + + return count; + } catch (error) { + OutputView.printError(error.message); + } + } + } + + #validateAmount(input) { + const amount = Number(input); + + if (Number.isNaN(amount)) { + throw new Error(ERROR_MESSAGES.AMOUNT_NAN); + } + if (amount <= 0) { + throw new Error(ERROR_MESSAGES.AMOUNT_NEGATIVE); + } + if (amount % LOTTO_CONFIG.PRICE_PER_TICKET !== 0) { + throw new Error(ERROR_MESSAGES.AMOUNT_UNIT); + } + + return amount; + } + + /** + @param {number} count + */ + issueLottos(count) { + // 1. 모델(LottoStore)에 로또 생성 요청 + this.#lottoStore.generateLottos(count); + + // 2. 모델에서 생성된 로또 목록 가져오기 + const lottos = this.#lottoStore.getLottos(); + + // test에서 lotto 몇개 구매했는지 뜨게 함 + OutputView.printPurchaseResult(count); + + // 3. 뷰(OutputView)에 출력 요청 + OutputView.printLottos(lottos); + } + + // 당첨 번호 관련 메서드 + /** + 당첨 번호 입력을 받고 유효성 검사를 통과할 때까지 반복 + @returns {Promise} - 유효성이 검증된 당첨 번호 배열 + */ + async getWinningNumbers() { + while (true) { + try { + const input = await InputView.readWinningNumbers(); + const numbers = this.#parseAndValidateWinningNumbers(input); + return numbers; + } catch (error) { + OutputView.printError(error.message); + } + } + } + + /** + 쉼표(,)로 구분된 문자열을 파싱하고 Lotto 모델을 통해 검증 + @param {string} input - 사용자 입력 문자열 + @returns {number[]} - 숫자 배열 + */ + #parseAndValidateWinningNumbers(input) { + const numbers = input.split(',').map((numStr) => { + const num = Number(numStr.trim()); // 공백 제거 후 숫자로 변환 + if (Number.isNaN(num)) { + // Lotto 생성자 전에 NaN 체크가 필요 + throw new Error(ERROR_MESSAGES.WINNING_NOT_NUMBER); + } + return num; + }); + + // Lotto 클래스의 생성자/유효성 검사 로직을 재사용 + // (길이, 중복, 범위, 정수 모두 검사됨) + new Lotto(numbers); + + return numbers; + } + + // 보너스 번호 관련 메서드 + + /** + 보너스 번호 입력을 받고 유효성 검사를 통과할 때까지 반복 + @param {number[]} winningNumbers - (중복 검사를 위한) 당첨 번호 배열 + @returns {Promise} - 유효성이 검증된 보너스 번호 + */ + async getBonusNumber(winningNumbers) { + while (true) { + try { + const input = await InputView.readBonusNumber(); + const number = this.#parseAndValidateBonusNumber(input, winningNumbers); + return number; + } catch (error) { + OutputView.printError(error.message); + } + } + } + + /** + 보너스 번호 문자열을 파싱하고 유효성 검사 + @param {string} input - 사용자 입력 문자열 + @param {number[]} winningNumbers - 당첨 번호 배열 + @returns {number} - 유효한 보너스 번호 + */ + #parseAndValidateBonusNumber(input, winningNumbers) { + const number = Number(input.trim()); + + // 숫자가 아니거나 정수가 아닐 경우 (소수, 문자 등) + if (Number.isNaN(number) || !Number.isInteger(number)) { + throw new Error(ERROR_MESSAGES.BONUS_NAN); + } + // 로또 번호의 유효 범위(예: 1~45)를 벗어나는 경우 + if (number < LOTTO_CONFIG.MIN_NUMBER || number > LOTTO_CONFIG.MAX_NUMBER) { + throw new Error(ERROR_MESSAGES.BONUS_RANGE); + } + // 이미 당첨 번호 배열에 포함된 숫자인 경우 (중복 방지 + if (winningNumbers.includes(number)) { + throw new Error(ERROR_MESSAGES.BONUS_DUPLICATE); + } + + return number; + } + + /** + 당첨 결과를 계산하고 출력을 요청 + @param {number[]} winningNumbers + @param {number} bonusNumber + */ + calculateAndShowResults(winningNumbers, bonusNumber) { + // 1. 모델(LottoStore)에서 로또 목록 가져오기 + const lottos = this.#lottoStore.getLottos(); + + // 2. 모델(PrizeCalculator)에 계산 요청 + const results = this.#prizeCalculator.calculateResults( + lottos, + winningNumbers, + bonusNumber + ); + + const totalPrize = this.#prizeCalculator.calculateTotalPrize(results); + + const rateOfReturn = this.#prizeCalculator.calculateRateOfReturn( + totalPrize, + this.#purchaseAmount // 컨트롤러에 저장된 구매 금액 사용 + ); + + // 3. 뷰(OutputView)에 출력 요청 + OutputView.printResults(results, rateOfReturn); + } +} + + +export default LottoController; diff --git a/src/model/LottoStore.js b/src/model/LottoStore.js new file mode 100644 index 000000000..995556c69 --- /dev/null +++ b/src/model/LottoStore.js @@ -0,0 +1,38 @@ +// src/model/LottoStore.js + +import { Random } from '@woowacourse/mission-utils'; +import { LOTTO_CONFIG } from '../LottoConfig.js'; +import Lotto from '../Lotto.js'; + +class LottoStore { + #lottos; + + constructor() { + this.#lottos = []; + } + + // 로또 생성 + generateLottos(count) { + for (let i = 0; i < count; i++) { + const numbers = this.#pickLottoNumbers(); + const lotto = new Lotto(numbers); + this.#lottos.push(lotto); + } + } + + // mission-utils 사용 + #pickLottoNumbers() { + return Random.pickUniqueNumbersInRange( + LOTTO_CONFIG.MIN_NUMBER, + LOTTO_CONFIG.MAX_NUMBER, + LOTTO_CONFIG.NUMBER_COUNT + ); + } + + // 생성 로또 목록을 반환 get + getLottos() { + return this.#lottos; + } +} + +export default LottoStore; \ No newline at end of file diff --git a/src/model/PrizeCalculator.js b/src/model/PrizeCalculator.js new file mode 100644 index 000000000..5bc0d32e3 --- /dev/null +++ b/src/model/PrizeCalculator.js @@ -0,0 +1,79 @@ +import { RANK, PRIZE_MONEY } from '../LottoConfig.js'; + +//당첨 결과 계산 및 수익률 계산을 담당 +class PrizeCalculator { + /** + 로또 1장의 당첨 등수를 판별 + @param {Lotto} lotto - 검사할 로또 객체 + @param {number[]} winningNumbers - 당첨 번호 + @param {number} bonusNumber - 보너스 번호 + @returns {string | null} - 당첨 등수 (RANK[key] 또는 null) + */ + #determineRank(lotto, winningNumbers, bonusNumber) { + const matchCount = lotto.countMatch(winningNumbers); + const hasBonus = lotto.hasBonus(bonusNumber); + + if (matchCount === 6) return RANK.FIRST; + if (matchCount === 5 && hasBonus) return RANK.SECOND; + if (matchCount === 5) return RANK.THIRD; + if (matchCount === 4) return RANK.FOURTH; + if (matchCount === 3) return RANK.FIFTH; + return null; + } + + /** + * 구매한 모든 로또의 당첨 결과를 집계 + * @param {Lotto[]} lottos - 구매한 로또 목록 + * @param {number[]} winningNumbers + * @param {number} bonusNumber + * @returns {Map} - 등수별 당첨 횟수 (e.g., Map{FIFTH: 1, ...}) + */ + calculateResults(lottos, winningNumbers, bonusNumber) { + // Map을 사용해 5등 -> 1등 순서(출력 순서)를 보장 + const results = new Map([ + [RANK.FIFTH, 0], + [RANK.FOURTH, 0], + [RANK.THIRD, 0], + [RANK.SECOND, 0], + [RANK.FIRST, 0], + ]); + + lottos.forEach((lotto) => { + const rank = this.#determineRank(lotto, winningNumbers, bonusNumber); + if (rank) { + results.set(rank, results.get(rank) + 1); + } + }); + + return results; + } + + /** + * 당첨 통계를 기반으로 총 상금 계산 + * @param {Map} results - 당첨 통계 + * @returns {number} - 총 상금 + */ + calculateTotalPrize(results) { + let totalPrize = 0; + results.forEach((count, rank) => { + totalPrize += (PRIZE_MONEY[rank] || 0) * count; + }); + return totalPrize; + } + + /** + * 총 상금과 구매 금액으로 수익률 계산 + * (소수점 둘째 자리에서 반올림) + * @param {number} totalPrize - 총 상금 + * @param {number} purchaseAmount - 총 구매 금액 + * @returns {number} - 수익률 (e.g., 62.5) + */ + calculateRateOfReturn(totalPrize, purchaseAmount) { + if (purchaseAmount === 0) return 0; + const rate = (totalPrize / purchaseAmount) * 100; + // 소수점 둘째 자리에서 반올림하여 첫째 자리까지 표시 + return Math.round(rate * 10) / 10; + } +} + +export default PrizeCalculator; diff --git a/src/view/InputView.js b/src/view/InputView.js new file mode 100644 index 000000000..3717de2d2 --- /dev/null +++ b/src/view/InputView.js @@ -0,0 +1,26 @@ +import { Console } from "@woowacourse/mission-utils"; + +const InputView = { + async readPurchaseAmount() { + const input = await Console.readLineAsync("구입금액을 입력해 주세요.\n"); + return input; + }, + + + // 당첨 번호 입력 + async readWinningNumbers() { + // 실행 예시에 따라, 로또 목록 출력 후 한 줄 띄고 질문합니다. + const input = await Console.readLineAsync('\n당첨 번호를 입력해 주세요.\n'); + return input; + }, + + + // 보너스 번호 입력 + async readBonusNumber() { + // 당첨 번호 입력 후 한 줄 띄고 질문합니다. + const input = await Console.readLineAsync('\n보너스 번호를 입력해 주세요.\n'); + return input; + }, +}; + +export default InputView; diff --git a/src/view/OutputView.js b/src/view/OutputView.js new file mode 100644 index 000000000..375d676db --- /dev/null +++ b/src/view/OutputView.js @@ -0,0 +1,50 @@ +import { Console } from '@woowacourse/mission-utils'; +import { RANK, PRIZE_MONEY } from '../LottoConfig.js'; + + +//출력 메시지 포맷 정의 +const PRIZE_FORMATTER = new Map([ + [RANK.FIFTH, `3개 일치 (${PRIZE_MONEY.FIFTH.toLocaleString()}원)`], + [RANK.FOURTH, `4개 일치 (${PRIZE_MONEY.FOURTH.toLocaleString()}원)`], + [RANK.THIRD, `5개 일치 (${PRIZE_MONEY.THIRD.toLocaleString()}원)`], + [RANK.SECOND, `5개 일치, 보너스 볼 일치 (${PRIZE_MONEY.SECOND.toLocaleString()}원)`], + [RANK.FIRST, `6개 일치 (${PRIZE_MONEY.FIRST.toLocaleString()}원)`], +]); + +const OutputView = { + printPurchaseResult(count) { + Console.print(`\n${count}개를 구매했습니다.`); + }, + + printLottos(lottos) { + lottos.forEach((lotto) => { + const numbers = lotto.getNumbers(); + Console.print(`[${numbers.join(', ')}]`); + }); + }, + + /** + 당첨 통계 및 수익률 출력 + @param {Map} results - 등수별 당첨 횟수 (Calculator에서 생성) + @param {number} rateOfReturn - 계산된 수익률 + */ + printResults(results, rateOfReturn) { + Console.print('\n당첨 통계'); + Console.print('---'); + + // PRIZE_FORMATTER의 순서(5등->1등)대로 출력 + PRIZE_FORMATTER.forEach((message, rank) => { + const count = results.get(rank) || 0; + Console.print(`${message} - ${count}개`); + }); + + // 요구사항: 소수점 둘째 자리에서 반올림 (ex 62.5%) + Console.print(`총 수익률은 ${rateOfReturn.toFixed(1)}%입니다.`); + }, + + printError(message) { + Console.print(message); + }, +}; + +export default OutputView; \ No newline at end of file