Skip to content
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
110 changes: 110 additions & 0 deletions __tests__/LottoSimulation.test.js
Original file line number Diff line number Diff line change
@@ -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('총 수익률은'));
});
});
55 changes: 55 additions & 0 deletions __tests__/PrizeCalculator.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
29 changes: 27 additions & 2 deletions src/App.js
Original file line number Diff line number Diff line change
@@ -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;
66 changes: 61 additions & 5 deletions src/Lotto.js
Original file line number Diff line number Diff line change
@@ -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;
Loading