Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,156 @@
# javascript-lotto-precourse

# 🔆 프로젝트 큰 흐름

1. `InputView`를 통해 로또 구입 금액 문자열을 입력 받는다.
1. `PurchaseAmount` 모델을 생성하며 `PurchaseAmountValidator`를 통해 유효성 검사를 실행한다.
2. 유효성 검사에 실패하면 `[ERROR]` 메시지를 출력하고 해당 지점부터 다시 입력을 받는다.
2. `LottoStore` 모델을 통해 `PurchaseAmount`만큼 로또(`Lotto` 인스턴스)를 구매한다.
1. `Random.pickUniqueNumbersInRange`를 사용해 로또 번호를 생성한다.
3. `OutputView`를 통해 구매한 로또 개수와 번호 목록을 출력한다.
4. `InputView`를 통해 당첨 번호 문자열을 입력 받는다.
1. `WinningLottoValidator`로 문자열 포맷(쉼표, 공백 등)을 검사한다.
2. `Lotto` 모델을 생성하며 `LottoValidator`로 6개의 번호에 대한 유효성(개수, 중복, 범위)을 검사한다.
3. 유효성 검사에 실패하면 `[ERROR]` 메시지를 출력하고 다시 입력을 받는다.
5. `InputView`를 통해 보너스 번호 문자열을 입력 받는다.
1. `WinningLotto` 모델을 생성하며 `WinningLottoValidator`로 보너스 번호의 유효성(범위, 당첨 번호와 중복 여부)을 검사한다.
2. 유효성 검사에 실패하면 `[ERROR]` 메시지를 출력하고 다시 입력을 받는다.
6. `LottoResult` 모델을 생성자에 구매한 로또 배열과 `WinningLotto`를 전달하여 생성한다.
1. `LottoResult`는 내부적으로 모든 로또를 `WinningLotto`와 비교하여 당첨 통계를 계산한다.
2. 당첨 통계를 바탕으로 총 수익률을 계산한다.
7. `OutputView`를 통해 당첨 통계 헤더 문구를 출력한다.
8. `OutputView`를 통해 등수별 당첨 개수와 수익률을 포맷에 맞게 출력한다.

# 📁 프로젝트 구조

```src
├── App.js # 애플리케이션 실행 로직 (run)
├── index.js # App 인스턴스 생성 및 실행
├── controllers/
│ └── LottoGameController.js # Controller (메인 로직, 입출력 흐름 제어)
├── constants/ # Constants (상수 관리)
│ ├── config.js ## 로또 가격, 번호 범위, 당첨 랭킹 등
│ └── messages.js ## 입출력 및 에러 메시지
├── domain/ # Model (핵심 로직 및 데이터 관리)
│ ├── PurchaseAmount.js ## 구입 금액 (1000원 단위 검증, 로또 개수 계산)
│ ├── Lotto.js ## 로또 1장 (번호 6개, 유효성 검증)
│ ├── WinningLotto.js ## 당첨 번호 (메인 6 + 보너스 1, 보너스 번호 검증)
│ ├── LottoStore.js ## 로또 생성 및 발급 (랜덤 번호 생성)
│ └── LottoResult.js ## 당첨 통계 및 수익률 계산
├── utils/ # Utilities (보조 도구)
│ └── Utils.js ## 문자열 분리, 숫자 포매팅
├── validate/ # Validator (유효성 검증 로직)
│ ├── Validator.js ## 공통 검증 (빈 값, 양의 정수, 숫자 범위, 중복)
│ ├── MoneyValidator.js ## 금액 관련 검증 (1000원 단위, 최대 크기)
│ ├── PurchaseAmountValidator.js ## 구입 금액 검증 조합
│ ├── LottoValidator.js ## 로또 번호 6개 배열 검증 (개수, 중복, 범위)
│ └── WinningLottoValidator.js ## 당첨/보너스 번호 검증 (문자열 포맷, 중복)
├── view/ # View (입출력 담당)
│ ├── InputView.js ## 사용자 입력 (readLineAsync)
│ └── OuputView.js ## 결과 출력 (print)
__tests__/ # Test
├── ApplicationTest.js ## 기능 테스트 (통합)
├── LottoTest.js ## Lotto 클래스 단위 테스트
├── PurchaseAmountTest.js ## PurchaseAmount 클래스 단위 테스트
├── WinningLottoTest.js ## WinningLotto 클래스 단위 테스트
```

# ✅ 구현할 기능 목록

### [공통]

- [x] `App.js`의 `run()` 메서드를 구현하여 프로그램 전체 흐름 제어
- [x] `LottoGameController.js`의 `play()` 메서드에서 메인 로직 흐름 관리
- [x] `constants` 폴더에 모든 입력/출력/에러 메시지와 설정을 정의
- [x] 유효성 검사 실패 시 `[ERROR]`가 포함된 메시지를 출력하고 해당 입력부터 다시 받도록 구현

### [View]

- [x] `InputView`: 구입 금액을 입력 받는 기능 구현
- [x] `InputView`: 당첨 번호를 입력 받는 기능 구현
- [x] `InputView`: 보너스 번호를 입력 받는 기능 구현
- [x] `OutputView`: 구매한 로또 개수와 번호 목록을 출력하는 기능 구현
- [x] `OutputView`: 당첨 통계 헤더("당첨 통계\n---")를 출력하는 기능 구현
- [x] `OutputView`: 각 등수별 당첨 결과를 포맷에 맞게 출력하는 기능 구현
- [x] `OutputView`: 총 수익률을 소수점 둘째 자리에서 반올림하여 출력하는 기능 구현

### [Validator]

- [x] `Validator`: 입력값이 비어있지 않은지 검증
- [x] `Validator`: 값이 양의 정수인지 검증
- [x] `Validator`: 값이 1~45 사이의 숫자인지 검증
- [x] `Validator`: 배열 내 중복된 숫자가 있는지 검증
- [x] `MoneyValidator`: 구입 금액이 1,000원 단위인지 검증
- [x] `MoneyValidator`: 구입 금액이 `Number.MAX_SAFE_INTEGER`를 넘지 않는지 검증
- [x] `WinningLottoValidator`: 당첨 번호 문자열에 구분자(`,`)가 포함되어 있는지 검증
- [x] `WinningLottoValidator`: 당첨 번호 문자열이 구분자(`,`)로 시작하거나 끝나지 않는지 검증
- [x] `LottoValidator`: 로또 번호가 6개인지 검증
- [x] `WinningLottoValidator`: 보너스 번호가 당첨 번호 6개와 중복되지 않는지 검증

### [Model]

- [x] **PurchaseAmount class**: 구입 금액을 관리
- [x] **Lotto class**: 로또 1장의 정보를 가짐 (번호 6개)
- [x] `property`: `#numbers` (오름차순 정렬)
- [x] `method`: `getNumbers()`
- [x] **WinningLotto class**: 당첨 번호와 보너스 번호를 가짐
- [x] `method`: `compare(lottoNumbers)` (로또 1장과 비교하여 {일치 개수, 보너스 일치 여부} 반환)
- [x] **LottoStore class**: 로또를 발급
- [x] `method`: `buyLottos(purchaseAmount)` (구입 금액만큼 로또 생성)
- [x] **LottoResult class**: 당첨 결과를 계산하고 통계를 가짐
- [x] `method`: `#calculateStats(...)` (모든 로또의 당첨 등수 계산)
- [x] `method`: `#calculateProfit(...)` (총 투자 대비 수익률 계산)
- [x] `method`: `getMatchCntInfo()`, `getProfitRate()`

### [Utils]

- [x] `Utils.js`: `splitBySeparator` (문자열을 구분자로 나누어 배열 반환)
- [x] `Utils.js`: `formatNumber` (숫자를 1,000 단위 쉼표가 있는 문자열로 변환)
- [x] `@woowacourse/mission-utils`: `Random.pickUniqueNumbersInRange`를 사용해 랜덤 번호 생성
- [x] `@woowacourse/mission-utils`: `Console.readLineAsync`와 `Console.print`로 입출력 처리

# ‼️ 예외 처리 고려 사항

### 0. 공통

- 값이 비어있지 않은지 검증
- [x] 값이 비어있다면 ERROR
- 값이 올바른 양의 정수 숫자 형식인지 검증
- [x] 값이 양의 정수가 아니라면 ERROR
- 값이 올바른 로또 번호 형식인지 검증
- [x] 값이 1부터 45 사이가 아니라면 ERROR
- [x] 기존 로또 번호와 중복이라면 ERROR

### 1. 로또 구입 금액 (money)

- [x] [공통] 값이 비어있지 않은지 검증
- [x] [공통] 값이 올바른 양의 정수 숫자 형식인지 검증
- [x] 값이 1000으로 나눠떨어지지 않는다면 ERROR
- [x] 값이 너무 크다면 ERROR

### 2. 당첨 번호 문자열 (winningNumberString)

- [x] [공통] 값이 비어있지 않은지 검증
- [x] 문자열에 구분자가 없다면 ERROR
- [x] 문자열이 구분자로 시작하거나 구분자로 끝나면 ERROR

### 3. 당첨 번호 배열 (winningNumbers)

- 배열의 각 값에 대해서
- [x] [공통] 값이 비어있지 않은지 검증
- [x] [공통] 값이 올바른 양의 정수 숫자 형식인지 검증
- [x] [공통] 값이 올바른 로또 번호 형식인지 검증
- [x] 배열의 길이가 6이 아니라면 ERROR

### 4. 보너스 번호 (bonusNumber)

- [x] [공통] 값이 비어있지 않은지 검증
- [x] [공통] 값이 올바른 양의 정수 숫자 형식인지 검증
- [x] [공통] 값이 올바른 로또 번호 형식인지 검증
100 changes: 51 additions & 49 deletions __tests__/ApplicationTest.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import App from "../src/App.js";
import { MissionUtils } from "@woowacourse/mission-utils";
import App from '../src/App.js';
import { MissionUtils } from '@woowacourse/mission-utils';

const mockQuestions = (inputs) => {
MissionUtils.Console.readLineAsync = jest.fn();

MissionUtils.Console.readLineAsync.mockImplementation(() => {
const input = inputs.shift();

return Promise.resolve(input);
});
};
Expand All @@ -19,79 +18,82 @@ const mockRandoms = (numbers) => {
};

const getLogSpy = () => {
const logSpy = jest.spyOn(MissionUtils.Console, "print");
const logSpy = jest.spyOn(MissionUtils.Console, 'print');
logSpy.mockClear();
return logSpy;
};

const runException = async (input) => {
const runException = async (invalidInput, precedingInputs = []) => {
// given
const logSpy = getLogSpy();

const RANDOM_NUMBERS_TO_END = [1, 2, 3, 4, 5, 6];
const INPUT_NUMBERS_TO_END = ["1000", "1,2,3,4,5,6", "7"];
const INPUTS_TO_END = ['1000', '1,2,3,4,5,6', '7'];

mockRandoms([RANDOM_NUMBERS_TO_END]);
mockQuestions([input, ...INPUT_NUMBERS_TO_END]);
mockQuestions([...precedingInputs, invalidInput, ...INPUTS_TO_END]);

// when
const app = new App();
await app.run();

// then
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[ERROR]"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[ERROR]'));
};

describe("로또 테스트", () => {
describe('로또 애플리케이션 예외 검사', () => {
beforeEach(() => {
jest.restoreAllMocks();
});

test("기능 테스트", async () => {
// given
const logSpy = getLogSpy();

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", "7"]);
describe('구입 금액 예외 검사', () => {
const cases = [
['숫자가 아닌 경우', '1000j'],
['1000원 단위가 아닌 경우', '1500'],
['0원인 경우', '0'],
['음수인 경우', '-1000'],
['공백인 경우', ''],
['소수인 경우', '1000.5'],
];

// when
const app = new App();
await app.run();
test.each(cases)('%s: "%s" 입력 시 [ERROR] 출력', async (title, input) => {
await runException(input);
});
});

// then
const logs = [
"8개를 구매했습니다.",
"[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]",
"3개 일치 (5,000원) - 1개",
"4개 일치 (50,000원) - 0개",
"5개 일치 (1,500,000원) - 0개",
"5개 일치, 보너스 볼 일치 (30,000,000원) - 0개",
"6개 일치 (2,000,000,000원) - 0개",
"총 수익률은 62.5%입니다.",
describe('당첨 번호 예외 검사', () => {
const precedingInput = ['1000'];
const cases = [
['숫자가 아닌 경우', '1,2,3,4,5,a'],
['숫자 범위를 벗어난 경우 (46)', '1,2,3,4,5,46'],
['숫자 범위를 벗어난 경우 (0)', '1,2,3,4,5,0'],
['중복된 숫자가 있는 경우', '1,2,3,4,5,5'],
['개수가 6개가 아닌 경우 (5개)', '1,2,3,4,5'],
['개수가 6개가 아닌 경우 (7개)', '1,2,3,4,5,6,7'],
['공백이 포함된 경우', '1,2,3,4,5, '],
['쉼표로 시작하는 경우', ',1,2,3,4,5'],
['쉼표로 끝나는 경우', '1,2,3,4,5,'],
['쉼표가 없는 경우', '1 2 3 4 5 6'],
['공백만 입력한 경우', ''],
];

logs.forEach((log) => {
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
test.each(cases)('%s: "%s" 입력 시 [ERROR] 출력', async (title, input) => {
await runException(input, precedingInput);
});
});

test("예외 테스트", async () => {
await runException("1000j");
describe('보너스 번호 예외 검사', () => {
const precedingInputs = ['1000', '1,2,3,4,5,6'];
const cases = [
['숫자가 아닌 경우', 'a'],
['숫자 범위를 벗어난 경우 (46)', '46'],
['숫자 범위를 벗어난 경우 (0)', '0'],
['당첨 번호와 중복된 경우', '6'],
['공백인 경우', ''],
['소수인 경우', '7.5'],
];

test.each(cases)('%s: "%s" 입력 시 [ERROR] 출력', async (title, input) => {
await runException(input, precedingInputs);
});
});
});
67 changes: 54 additions & 13 deletions __tests__/LottoTest.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,59 @@
import Lotto from "../src/Lotto";
import Lotto from '../src/models/Lotto.js';
import { ERROR_MESSAGE } from '../src/constants/messages.js';

describe("로또 클래스 테스트", () => {
test("로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.", () => {
expect(() => {
new Lotto([1, 2, 3, 4, 5, 6, 7]);
}).toThrow("[ERROR]");
});
describe('Lotto 클래스 단위 테스트', () => {
describe('Lotto 번호 예외 검사', () => {
// cases
const INVALID_COUNT_CASE = [[[1, 2, 3, 4, 5, 6, 7]], [[1, 2, 3, 4, 5]]];

const LOTTO_NUMBER_OUT_OF_RANGE = [[[1, 2, 3, 4, 5, 0]], [[1, 2, 3, 4, 5, 46]]];

const INPUT_NOT_AN_POSITIVE_INTEGER_CASE = [
['숫자가 아닌 값', ['1', '2', '3', '4', '5', 'a']],
['공백 문자', ['1', '2', '3', '4', '5', ' ']],
['소수', [1, 2, 3, 4, 5, 1.5]],
];

// INVALID_COUNT_CASE
test.each(INVALID_COUNT_CASE)('로또 번호의 개수가 6개가 아니면 예외가 발생한다', (numbers) => {
expect(() => {
new Lotto(numbers);
}).toThrow(ERROR_MESSAGE.WINNING_NUMBERS.INVALID_COUNT);
});

// TODO: 테스트가 통과하도록 프로덕션 코드 구현
test("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.", () => {
expect(() => {
new Lotto([1, 2, 3, 4, 5, 5]);
}).toThrow("[ERROR]");
// LOTTO_NUMBER_OUT_OF_RANGE
test.each(LOTTO_NUMBER_OUT_OF_RANGE)('로또 번호가 범위 안의 숫자가 아니면 예외가 발생한다.', (numbers) => {
expect(() => {
new Lotto(numbers);
}).toThrow(ERROR_MESSAGE.WINNING_NUMBERS.LOTTO_NUMBER_OUT_OF_RANGE);
});

// INPUT_NOT_AN_POSITIVE_INTEGER_CASE
test.each(INPUT_NOT_AN_POSITIVE_INTEGER_CASE)('%s: %p 입력 시 예외가 발생한다.', (title, numbers) => {
expect(() => {
new Lotto(numbers);
}).toThrow(ERROR_MESSAGE.COMMON.INPUT_NOT_AN_POSITIVE_INTEGER);
});

// LOTTO_NUMBER_DUPLICATED_CASE
test('로또 번호에 중복된 숫자가 있으면 예외가 발생한다.', () => {
expect(() => {
new Lotto([1, 2, 3, 4, 5, 5]);
}).toThrow(ERROR_MESSAGE.COMMON.LOTTO_NUMBER_DUPLICATED);
});
});

// TODO: 추가 기능 구현에 따른 테스트 코드 작성
describe('Lotto 클래스 기능 검사', () => {
test('로또 번호가 오름차순으로 정렬되어 저장된다.', () => {
const numbers = [6, 5, 4, 3, 2, 1];
const lotto = new Lotto(numbers);
expect(lotto.getNumbers()).toEqual([1, 2, 3, 4, 5, 6]);
});

test('문자열 숫자를 입력해도 숫자로 변환되어 저장된다.', () => {
const numbers = ['6', '5', '4', '3', '2', '1'];
const lotto = new Lotto(numbers);
expect(lotto.getNumbers()).toEqual([1, 2, 3, 4, 5, 6]);
});
});
});
Loading