diff --git a/README.md b/README.md index 15bb106b5..ef91dae48 100644 --- a/README.md +++ b/README.md @@ -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] [곡톡] 값이 μ˜¬λ°”λ₯Έ 둜또 번호 ν˜•μ‹μΈμ§€ 검증 diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js index 872380c9c..ff08e6d3d 100644 --- a/__tests__/ApplicationTest.js +++ b/__tests__/ApplicationTest.js @@ -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); }); }; @@ -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); + }); }); }); diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js index 409aaf69b..edd432aee 100644 --- a/__tests__/LottoTest.js +++ b/__tests__/LottoTest.js @@ -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]); + }); + }); }); diff --git a/__tests__/PurchaseAmountTest.js b/__tests__/PurchaseAmountTest.js new file mode 100644 index 000000000..75f48cf23 --- /dev/null +++ b/__tests__/PurchaseAmountTest.js @@ -0,0 +1,33 @@ +import PurchaseAmount from '../src/models/PurchaseAmount.js'; +import { ERROR_MESSAGE } from '../src/constants/messages.js'; + +describe('PurchaseAmount 클래슀 λ‹¨μœ„ ν…ŒμŠ€νŠΈ', () => { + describe('κ΅¬μž… κΈˆμ•‘ μ˜ˆμ™Έ 검사', () => { + const cases = [ + [ERROR_MESSAGE.COMMON.INPUT_EMPTY, '빈 κ°’', ''], + [ERROR_MESSAGE.COMMON.INPUT_NOT_AN_POSITIVE_INTEGER, 'μˆ«μžκ°€ μ•„λ‹Œ κ°’', 'abc'], + [ERROR_MESSAGE.COMMON.INPUT_NOT_AN_POSITIVE_INTEGER, '0', '0'], + [ERROR_MESSAGE.COMMON.INPUT_NOT_AN_POSITIVE_INTEGER, '음수', '-1000'], + [ERROR_MESSAGE.COMMON.INPUT_NOT_AN_POSITIVE_INTEGER, 'μ†Œμˆ˜', '1000.5'], + [ERROR_MESSAGE.MONEY.INVALID_UNIT, '1000원 λ‹¨μœ„κ°€ μ•„λ‹Œ κ°’', '1500'], + ]; + + test.each(cases)('%s: "%s" μž…λ ₯ μ‹œ μ˜ˆμ™Έ λ°œμƒ', (errorMsg, title, input) => { + expect(() => { + new PurchaseAmount(input); + }).toThrow(errorMsg); + }); + }); + + describe('κΈ°λŠ₯ 검사', () => { + test('8000원 μž…λ ₯ μ‹œ 둜또 8개λ₯Ό κ³„μ‚°ν•œλ‹€', () => { + const purchaseAmount = new PurchaseAmount('8000'); + expect(purchaseAmount.calculateLottoCount()).toBe(8); + }); + + test('1000원 μž…λ ₯ μ‹œ 둜또 1개λ₯Ό κ³„μ‚°ν•œλ‹€', () => { + const purchaseAmount = new PurchaseAmount('1000'); + expect(purchaseAmount.calculateLottoCount()).toBe(1); + }); + }); +}); diff --git a/__tests__/WinningLottoTest.js b/__tests__/WinningLottoTest.js new file mode 100644 index 000000000..fdaf767d3 --- /dev/null +++ b/__tests__/WinningLottoTest.js @@ -0,0 +1,69 @@ +import WinningLotto from '../src/models/WinningLotto.js'; +import { ERROR_MESSAGE } from '../src/constants/messages.js'; + +describe('WinningLotto 클래슀 λ‹¨μœ„ ν…ŒμŠ€νŠΈ', () => { + const MAIN_NUMBERS = [1, 2, 3, 4, 5, 6]; + const BONUS_NUMBER = '7'; + + describe('λ³΄λ„ˆμŠ€ 번호 μ˜ˆμ™Έ 검사', () => { + const cases = [ + [ERROR_MESSAGE.COMMON.INPUT_EMPTY, '빈 κ°’', ''], + [ERROR_MESSAGE.COMMON.INPUT_NOT_AN_POSITIVE_INTEGER, 'μˆ«μžκ°€ μ•„λ‹Œ κ°’', 'a'], + [ERROR_MESSAGE.COMMON.INPUT_NOT_AN_POSITIVE_INTEGER, '0', '0'], + [ERROR_MESSAGE.COMMON.INPUT_NOT_AN_POSITIVE_INTEGER, '음수', '-10'], + [ERROR_MESSAGE.COMMON.INPUT_NOT_AN_POSITIVE_INTEGER, 'μ†Œμˆ˜', '1.5'], + [ERROR_MESSAGE.COMMON.LOTTO_NUMBER_OUT_OF_RANGE, 'λ²”μœ„ λ°–μ˜ κ°’ (46)', '46'], + [ERROR_MESSAGE.COMMON.LOTTO_NUMBER_DUPLICATED, '당첨 λ²ˆν˜Έμ™€ μ€‘λ³΅λœ κ°’', '6'], + ]; + + test.each(cases)('%s: "%s" μž…λ ₯ μ‹œ μ˜ˆμ™Έ λ°œμƒ', (errorMsg, title, bonusNumber) => { + expect(() => { + new WinningLotto(MAIN_NUMBERS, bonusNumber); + }).toThrow(errorMsg); + }); + }); + + describe('κΈ°λŠ₯ 검사', () => { + test('정상 μž…λ ₯ μ‹œ WinningLotto μΈμŠ€ν„΄μŠ€κ°€ μƒμ„±λœλ‹€', () => { + expect(() => { + new WinningLotto(MAIN_NUMBERS, BONUS_NUMBER); + }).not.toThrow(); + }); + + test('compare λ©”μ„œλ“œ: 6개 일치', () => { + const winningLotto = new WinningLotto(MAIN_NUMBERS, BONUS_NUMBER); + const myLotto = [1, 2, 3, 4, 5, 6]; + expect(winningLotto.compare(myLotto)).toEqual({ + matchCount: 6, + hasBonus: false, + }); + }); + + test('compare λ©”μ„œλ“œ: 5개 + λ³΄λ„ˆμŠ€ 일치 (2λ“±)', () => { + const winningLotto = new WinningLotto(MAIN_NUMBERS, BONUS_NUMBER); + const myLotto = [1, 2, 3, 4, 5, 7]; + expect(winningLotto.compare(myLotto)).toEqual({ + matchCount: 5, + hasBonus: true, + }); + }); + + test('compare λ©”μ„œλ“œ: 5개 일치 (3λ“±)', () => { + const winningLotto = new WinningLotto(MAIN_NUMBERS, BONUS_NUMBER); + const myLotto = [1, 2, 3, 4, 5, 8]; + expect(winningLotto.compare(myLotto)).toEqual({ + matchCount: 5, + hasBonus: false, + }); + }); + + test('compare λ©”μ„œλ“œ: 3개 일치 (5λ“±)', () => { + const winningLotto = new WinningLotto(MAIN_NUMBERS, BONUS_NUMBER); + const myLotto = [1, 2, 3, 8, 9, 10]; + expect(winningLotto.compare(myLotto)).toEqual({ + matchCount: 3, + hasBonus: false, + }); + }); + }); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5d..afc60666d 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,15 @@ +import LottoGameController from './controllers/LottoGameController.js'; +import OutputView from './views/OuputView.js'; class App { - async run() {} + async run() { + try { + const lottoGameController = new LottoGameController(); + await lottoGameController.play(); + } catch (error) { + OutputView.printMessage(error.message); + throw error; + } + } } export default App; diff --git a/src/Lotto.js b/src/Lotto.js deleted file mode 100644 index cb0b1527e..000000000 --- a/src/Lotto.js +++ /dev/null @@ -1,18 +0,0 @@ -class Lotto { - #numbers; - - constructor(numbers) { - this.#validate(numbers); - this.#numbers = numbers; - } - - #validate(numbers) { - if (numbers.length !== 6) { - throw new Error("[ERROR] 둜또 λ²ˆν˜ΈλŠ” 6κ°œμ—¬μ•Ό ν•©λ‹ˆλ‹€."); - } - } - - // TODO: μΆ”κ°€ κΈ°λŠ₯ κ΅¬ν˜„ -} - -export default Lotto; diff --git a/src/constants/config.js b/src/constants/config.js new file mode 100644 index 000000000..a9339a088 --- /dev/null +++ b/src/constants/config.js @@ -0,0 +1,37 @@ +export const LOTTO_NUMBER_SEPARATOR = ','; + +export const LOTTO_COST = 1000; + +export const LOTTO_INFO = Object.freeze({ + RANGE_START: 1, + RANGE_END: 45, + TOTAL_LENGTH: 6, +}); + +export const LOTTO_RANK = Object.freeze({ + '1st': Object.freeze({ + matchCount: 6, + money: 2000000000, + requireBonus: false, + }), + '2nd': Object.freeze({ + matchCount: 5, + money: 30000000, + requireBonus: true, + }), + '3rd': Object.freeze({ + matchCount: 5, + money: 1500000, + requireBonus: false, + }), + '4th': Object.freeze({ + matchCount: 4, + money: 50000, + requireBonus: false, + }), + '5th': Object.freeze({ + matchCount: 3, + money: 5000, + requireBonus: false, + }), +}); diff --git a/src/constants/messages.js b/src/constants/messages.js new file mode 100644 index 000000000..2d3a83770 --- /dev/null +++ b/src/constants/messages.js @@ -0,0 +1,40 @@ +import { LOTTO_COST, LOTTO_INFO, LOTTO_NUMBER_SEPARATOR } from './config.js'; + +export const PREFIX = Object.freeze({ + ERROR: '[ERROR]', +}); + +export const INPUT_MESSAGE = Object.freeze({ + MONEY: `κ΅¬μž…κΈˆμ•‘μ„ μž…λ ₯ν•΄ μ£Όμ„Έμš”.\n`, + WINNING_NUMBER_STRING: `\n당첨 번호λ₯Ό μž…λ ₯ν•΄ μ£Όμ„Έμš”.\n`, + BONUS_NUMBER: `\nλ³΄λ„ˆμŠ€ 번호λ₯Ό μž…λ ₯ν•΄ μ£Όμ„Έμš”.\n`, +}); + +export const OUTPUT_MESSAGE = Object.freeze({ + PURCHASE_RESULT: (purchaseCnt) => `\n${purchaseCnt}개λ₯Ό κ΅¬λ§€ν–ˆμŠ΅λ‹ˆλ‹€.`, + WINNING_STATISTICS_HEADER: `\n당첨 톡계\n---`, + WINNING_MATCH_COUNT_INFO: (matchCnt) => `${matchCnt}개 일치`, + WINNING_PRIZE_MONEY: (prizeMoney) => `(${prizeMoney}원)`, + WINNING_COUNT: (winningCnt) => `${winningCnt}개`, + PROFIT_RATE: (rate) => `총 수읡λ₯ μ€ ${rate}%μž…λ‹ˆλ‹€.`, +}); + +export const ERROR_MESSAGE = Object.freeze({ + COMMON: Object.freeze({ + INPUT_EMPTY: `${PREFIX.ERROR} μž…λ ₯값은 λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€.`, + INPUT_NOT_AN_POSITIVE_INTEGER: `${PREFIX.ERROR} 값은 μ–‘μ˜ μ •μˆ˜(μžμ—°μˆ˜)만 μž…λ ₯λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€.`, + LOTTO_NUMBER_OUT_OF_RANGE: `${PREFIX.ERROR} 둜또 λ²ˆν˜ΈλŠ” ${LOTTO_INFO.RANGE_START}λΆ€ν„° ${LOTTO_INFO.RANGE_END} μ‚¬μ΄μ˜ μˆ«μžμ—¬μ•Ό ν•©λ‹ˆλ‹€.`, + LOTTO_NUMBER_DUPLICATED: `${PREFIX.ERROR} 둜또 λ²ˆν˜ΈλŠ” 쀑볡될 수 μ—†μŠ΅λ‹ˆλ‹€.`, + }), + MONEY: Object.freeze({ + INVALID_UNIT: `${PREFIX.ERROR} κ΅¬μž… κΈˆμ•‘μ€ ${LOTTO_COST}원 λ‹¨μœ„λ‘œ μž…λ ₯ν•΄μ•Ό ν•©λ‹ˆλ‹€.`, + TOO_LARGE: `${PREFIX.ERROR} κ΅¬μž… κΈˆμ•‘μ΄ λ„ˆλ¬΄ ν½λ‹ˆλ‹€.`, + }), + WINNING_NUMBER_STRING: Object.freeze({ + NO_SEPARATOR: `${PREFIX.ERROR} 당첨 λ²ˆν˜ΈλŠ” '${LOTTO_NUMBER_SEPARATOR}' κΈ°μ€€μœΌλ‘œ ꡬ뢄해야 ν•©λ‹ˆλ‹€.`, + INVALID_FORMAT: `${PREFIX.ERROR} 잘λͺ»λœ μž…λ ₯ ν˜•μ‹μž…λ‹ˆλ‹€.`, + }), + WINNING_NUMBERS: Object.freeze({ + INVALID_COUNT: `${PREFIX.ERROR} 당첨 λ²ˆν˜ΈλŠ” ${LOTTO_INFO.TOTAL_LENGTH}κ°œμ—¬μ•Ό ν•©λ‹ˆλ‹€.`, + }), +}); diff --git a/src/controllers/LottoGameController.js b/src/controllers/LottoGameController.js new file mode 100644 index 000000000..e56167605 --- /dev/null +++ b/src/controllers/LottoGameController.js @@ -0,0 +1,102 @@ +import { INPUT_MESSAGE } from '../constants/messages.js'; +import { LOTTO_NUMBER_SEPARATOR, LOTTO_RANK } from '../constants/config.js'; + +import { splitBySeparator } from '../utils/Utils.js'; + +import InputView from '../views/InputView.js'; +import OutputView from '../views/OuputView.js'; + +import PurchaseAmount from '../models/PurchaseAmount.js'; +import Lotto from '../models/Lotto.js'; +import LottoStore from '../models/LottoStore.js'; +import WinningLotto from '../models/WinningLotto.js'; +import LottoResult from '../models/LottoResult.js'; + +import WinningLottoValidator from '../validates/WinningLottoValidator.js'; + +class LottoGameController { + #lottos; + #winningLotto; + + constructor() { + this.#lottos = []; + } + + async play() { + const purchaseAmount = await this.#getValidPurchaseAmount(); + this.#buyLottos(purchaseAmount); + this.#winningLotto = await this.#getValidWinningLotto(); + this.#showResults(); + } + + async #readInputWithValidation(readMsg, validateFunc) { + while (true) { + try { + const input = await InputView.readStringWithMsg(readMsg); + return validateFunc(input.trim()); + } catch (error) { + OutputView.printMessage(error.message); + } + } + } + + async #getValidPurchaseAmount() { + return this.#readInputWithValidation(INPUT_MESSAGE.MONEY, (input) => new PurchaseAmount(input)); + } + + #buyLottos(purchaseAmount) { + const lottoStore = new LottoStore(); + this.#lottos = lottoStore.buyLottos(purchaseAmount); + + const lottoNumberArrays = this.#lottos.map((lotto) => lotto.getNumbers()); + OutputView.printPurchasedLottos(lottoNumberArrays); + } + + async #getValidWinningLotto() { + const mainNumbers = await this.#getValidWinningMainNumbers(); + + const winningLotto = await this.#readInputWithValidation( + INPUT_MESSAGE.BONUS_NUMBER, + (bonusInput) => new WinningLotto(mainNumbers.getNumbers(), bonusInput.trim()) + ); + return winningLotto; + } + + async #getValidWinningMainNumbers() { + return this.#readInputWithValidation(INPUT_MESSAGE.WINNING_NUMBER_STRING, (input) => { + WinningLottoValidator.validateWinningNumberString(input); + + const numbers = splitBySeparator(input, LOTTO_NUMBER_SEPARATOR); + return new Lotto(numbers); + }); + } + + #showResults() { + const lottoNumberArrays = this.#lottos.map((lotto) => lotto.getNumbers()); + const lottoResult = new LottoResult(lottoNumberArrays, this.#winningLotto); + + const matchCnt = lottoResult.getMatchCntInfo(); + const profitRate = lottoResult.getProfitRate(); + const resultData = this.#prepareResultData(matchCnt); + + OutputView.printResult(resultData, profitRate); + } + + #prepareResultData(matchCnt) { + const rankKeys = Object.keys(LOTTO_RANK).reverse(); + + return rankKeys.map((rankKey) => { + const { matchCount, money, requireBonus } = LOTTO_RANK[rankKey]; + const count = matchCnt[rankKey]; + + return { + matchCount, + money, + requireBonus, + count, + }; + }); + } +} + +export default LottoGameController; diff --git a/src/index.js b/src/index.js index 02a1d389e..9daefc93f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,4 @@ -import App from "./App.js"; +import App from './App.js'; const app = new App(); await app.run(); diff --git a/src/models/Lotto.js b/src/models/Lotto.js new file mode 100644 index 000000000..a8912d97a --- /dev/null +++ b/src/models/Lotto.js @@ -0,0 +1,19 @@ +import LottoValidator from '../validates/LottoValidator.js'; + +class Lotto { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = numbers.map(Number).sort((a, b) => a - b); + } + #validate(numbers) { + LottoValidator.validateLottoNumbers(numbers); + } + + getNumbers() { + return this.#numbers; + } +} + +export default Lotto; diff --git a/src/models/LottoResult.js b/src/models/LottoResult.js new file mode 100644 index 000000000..2b3a8f3b7 --- /dev/null +++ b/src/models/LottoResult.js @@ -0,0 +1,61 @@ +import { LOTTO_COST, LOTTO_RANK } from '../constants/config.js'; + +class LottoResult { + #matchCntInfo; + #profitRate; + + constructor(lottos, winningLotto) { + this.#matchCntInfo = this.#initMatchCntInfo(); + this.#calculateResult(lottos, winningLotto); + this.#profitRate = this.#calculateProfit(lottos.length); + } + + #initMatchCntInfo() { + return Object.keys(LOTTO_RANK).reduce((matchCnt, rankKey) => { + matchCnt[rankKey] = 0; + return matchCnt; + }, {}); + } + + #calculateResult(lottos, winningLotto) { + lottos.forEach((lotto) => { + const { matchCount, hasBonus } = winningLotto.compare(lotto); + const rankKey = this.#getRankKey(matchCount, hasBonus); + + if (rankKey) this.#matchCntInfo[rankKey] += 1; + }); + } + + #getRankKey(matchCount, hasBonus) { + if (matchCount === 6) return '1st'; + if (matchCount === 5 && hasBonus) return '2nd'; + if (matchCount === 5 && !hasBonus) return '3rd'; + if (matchCount === 4) return '4th'; + if (matchCount === 3) return '5th'; + return null; + } + + #calculateProfit(totalLottoCount) { + let totalPrize = 0; + Object.entries(this.#matchCntInfo).forEach(([rankKey, count]) => { + totalPrize += LOTTO_RANK[rankKey].money * count; + }); + + const totalCost = totalLottoCount * LOTTO_COST; + + if (totalCost === 0) return 0; + + const profitRate = (totalPrize / totalCost) * 100; + return profitRate; + } + + getMatchCntInfo() { + return this.#matchCntInfo; + } + + getProfitRate() { + return this.#profitRate; + } +} + +export default LottoResult; diff --git a/src/models/LottoStore.js b/src/models/LottoStore.js new file mode 100644 index 000000000..89b2a248c --- /dev/null +++ b/src/models/LottoStore.js @@ -0,0 +1,28 @@ +import { Random } from '@woowacourse/mission-utils'; +import { LOTTO_INFO } from '../constants/config.js'; +import Lotto from './Lotto.js'; + +class LottoStore { + buyLottos(purchaseAmount) { + const lottoCount = purchaseAmount.calculateLottoCount(); + + const lottos = this.#generateLottos(lottoCount); + return lottos; + } + + #generateLottos(lottoCount) { + const lottos = []; + + for (let _ = 0; _ < lottoCount; _++) { + const numbers = Random.pickUniqueNumbersInRange( + LOTTO_INFO.RANGE_START, + LOTTO_INFO.RANGE_END, + LOTTO_INFO.TOTAL_LENGTH + ); + lottos.push(new Lotto(numbers)); + } + return lottos; + } +} + +export default LottoStore; diff --git a/src/models/PurchaseAmount.js b/src/models/PurchaseAmount.js new file mode 100644 index 000000000..df30c3536 --- /dev/null +++ b/src/models/PurchaseAmount.js @@ -0,0 +1,21 @@ +import PurchaseAmountValidator from '../validates/PurchaseAmountValidator.js'; + +class PurchaseAmount { + #money; + + constructor(moneyInput) { + this.#validate(moneyInput); + this.#money = moneyInput; + } + + calculateLottoCount() { + const money = Number(this.#money); + return money / 1000; + } + + #validate(money) { + PurchaseAmountValidator.validatePurchaseAmount(money); + } +} + +export default PurchaseAmount; diff --git a/src/models/WinningLotto.js b/src/models/WinningLotto.js new file mode 100644 index 000000000..24dee7a4a --- /dev/null +++ b/src/models/WinningLotto.js @@ -0,0 +1,29 @@ +import WinningLottoValidator from '../validates/WinningLottoValidator.js'; + +class WinningLotto { + #mainNumbers; + #bonusNumber; + + constructor(mainNumbers, bonusNumber) { + this.#validate(mainNumbers, bonusNumber); + this.#mainNumbers = mainNumbers; + this.#bonusNumber = Number(bonusNumber); + } + + compare(lottoNumbers) { + const winningNumbers = this.#mainNumbers; + const matchCount = winningNumbers.filter((number) => lottoNumbers.includes(number)).length; + const hasBonus = lottoNumbers.includes(this.#bonusNumber); + + return { + matchCount, + hasBonus, + }; + } + + #validate(mainNumbers, bonusNumber) { + WinningLottoValidator.validateBonusNumber(mainNumbers, bonusNumber); + } +} + +export default WinningLotto; diff --git a/src/utils/Utils.js b/src/utils/Utils.js new file mode 100644 index 000000000..4ce6fa1e2 --- /dev/null +++ b/src/utils/Utils.js @@ -0,0 +1,9 @@ +export function splitBySeparator(string, separator) { + return string.trim().split(separator); +} + +export function formatNumber(number) { + return number.toLocaleString('ko-KR', { + maximumFractionDigits: 1, + }); +} diff --git a/src/validates/LottoValidator.js b/src/validates/LottoValidator.js new file mode 100644 index 000000000..9e9b4d96f --- /dev/null +++ b/src/validates/LottoValidator.js @@ -0,0 +1,28 @@ +import { LOTTO_INFO } from '../constants/config.js'; +import { ERROR_MESSAGE } from '../constants/messages.js'; +import Validator from './Validator.js'; + +export default { + validateSingleNumber(number) { + const numStr = String(number); + Validator.isInputEmpty(numStr); + Validator.isPositiveInteger(numStr); + Validator.isNumberInRange(number); + }, + + validateLottolength(length) { + if (length !== LOTTO_INFO.TOTAL_LENGTH) { + throw new Error(ERROR_MESSAGE.WINNING_NUMBERS.INVALID_COUNT); + } + }, + + validateLottoNumbers(numbers) { + numbers.forEach((number) => { + this.validateSingleNumber(number); + }); + + Validator.duplicatesNumber(numbers); + + this.validateLottolength(numbers.length); + }, +}; diff --git a/src/validates/MoneyValidator.js b/src/validates/MoneyValidator.js new file mode 100644 index 000000000..eefb0240d --- /dev/null +++ b/src/validates/MoneyValidator.js @@ -0,0 +1,15 @@ +import { ERROR_MESSAGE } from '../constants/messages.js'; + +export default { + isValidUnit(money) { + if (Number(money) % 1000 !== 0) { + throw new Error(ERROR_MESSAGE.MONEY.INVALID_UNIT); + } + }, + + tooLarge(money) { + if (BigInt(money) > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(ERROR_MESSAGE.MONEY.TOO_LARGE); + } + }, +}; diff --git a/src/validates/PurchaseAmountValidator.js b/src/validates/PurchaseAmountValidator.js new file mode 100644 index 000000000..ddb79d34c --- /dev/null +++ b/src/validates/PurchaseAmountValidator.js @@ -0,0 +1,11 @@ +import Validator from './Validator.js'; +import MoneyValidator from './MoneyValidator.js'; + +export default { + validatePurchaseAmount(money) { + Validator.isInputEmpty(money); + Validator.isPositiveInteger(money); + MoneyValidator.isValidUnit(money); + MoneyValidator.tooLarge(money); + }, +}; diff --git a/src/validates/Validator.js b/src/validates/Validator.js new file mode 100644 index 000000000..e90b78b66 --- /dev/null +++ b/src/validates/Validator.js @@ -0,0 +1,32 @@ +import { ERROR_MESSAGE } from '../constants/messages.js'; +import { LOTTO_INFO } from '../constants/config.js'; + +const POSTIVIE_INTEGER_REGEX = /^[1-9]\d*$/; + +export default { + isInputEmpty(input) { + if (input === '') { + throw new Error(ERROR_MESSAGE.COMMON.INPUT_EMPTY); + } + }, + + isPositiveInteger(input) { + if (!POSTIVIE_INTEGER_REGEX.test(input)) { + throw new Error(ERROR_MESSAGE.COMMON.INPUT_NOT_AN_POSITIVE_INTEGER); + } + }, + + isNumberInRange(_number) { + const number = Number(_number); + if (number < LOTTO_INFO.RANGE_START || number > LOTTO_INFO.RANGE_END) { + throw new Error(ERROR_MESSAGE.COMMON.LOTTO_NUMBER_OUT_OF_RANGE); + } + }, + + duplicatesNumber(numbers) { + const numberSet = new Set(numbers); + if (numberSet.size !== numbers.length) { + throw new Error(ERROR_MESSAGE.COMMON.LOTTO_NUMBER_DUPLICATED); + } + }, +}; diff --git a/src/validates/WinningLottoValidator.js b/src/validates/WinningLottoValidator.js new file mode 100644 index 000000000..d0e4b5a4f --- /dev/null +++ b/src/validates/WinningLottoValidator.js @@ -0,0 +1,33 @@ +import { LOTTO_NUMBER_SEPARATOR } from '../constants/config.js'; +import { ERROR_MESSAGE } from '../constants/messages.js'; +import Validator from './Validator.js'; + +export default { + isSeparatorExist(input) { + if (!input.includes(LOTTO_NUMBER_SEPARATOR)) { + throw new Error(ERROR_MESSAGE.WINNING_NUMBER_STRING.NO_SEPARATOR); + } + }, + + correctFormat(input) { + if (input.startsWith(LOTTO_NUMBER_SEPARATOR) || input.endsWith(LOTTO_NUMBER_SEPARATOR)) { + throw new Error(ERROR_MESSAGE.WINNING_NUMBER_STRING.INVALID_FORMAT); + } + }, + + validateWinningNumberString(input) { + Validator.isInputEmpty(input); + this.isSeparatorExist(input); + this.correctFormat(input); + }, + + validateBonusNumber(mainNumbers, bonusNumber) { + Validator.isInputEmpty(bonusNumber); + Validator.isPositiveInteger(bonusNumber); + Validator.isNumberInRange(bonusNumber); + + if (mainNumbers.includes(Number(bonusNumber))) { + throw new Error(ERROR_MESSAGE.COMMON.LOTTO_NUMBER_DUPLICATED); + } + }, +}; diff --git a/src/views/InputView.js b/src/views/InputView.js new file mode 100644 index 000000000..f573adf7e --- /dev/null +++ b/src/views/InputView.js @@ -0,0 +1,10 @@ +import { Console } from '@woowacourse/mission-utils'; + +const InputView = { + async readStringWithMsg(message) { + const input = await Console.readLineAsync(message); + return input; + }, +}; + +export default InputView; diff --git a/src/views/OuputView.js b/src/views/OuputView.js new file mode 100644 index 000000000..c27931606 --- /dev/null +++ b/src/views/OuputView.js @@ -0,0 +1,42 @@ +import { Console } from '@woowacourse/mission-utils'; +import { OUTPUT_MESSAGE } from '../constants/messages.js'; +import { LOTTO_NUMBER_SEPARATOR } from '../constants/config.js'; +import { formatNumber } from '../utils/Utils.js'; +const OutputView = { + printPurchasedLottos(lottoNumberArrays) { + Console.print(OUTPUT_MESSAGE.PURCHASE_RESULT(lottoNumberArrays.length)); + + lottoNumberArrays.forEach((numbers) => { + Console.print(`[${numbers.join(`${LOTTO_NUMBER_SEPARATOR} `)}]`); + }); + }, + + printResult(resultData, profitRate) { + Console.print(OUTPUT_MESSAGE.WINNING_STATISTICS_HEADER); + + this.printWinningInfo(resultData); + + const formattedProfitRate = Number(formatNumber(profitRate)).toFixed(1); + Console.print(OUTPUT_MESSAGE.PROFIT_RATE(formattedProfitRate)); + }, + + printWinningInfo(resultData) { + resultData.forEach((rank) => { + const { matchCount, money, requireBonus, count } = rank; + + let matchMessage = OUTPUT_MESSAGE.WINNING_MATCH_COUNT_INFO(matchCount); + if (requireBonus) matchMessage += ', λ³΄λ„ˆμŠ€ λ³Ό 일치'; + + const formattedMoney = formatNumber(money); + const prizeMessage = OUTPUT_MESSAGE.WINNING_PRIZE_MONEY(formattedMoney); + + Console.print(`${matchMessage} ${prizeMessage} - ${count}개`); + }); + }, + + printMessage(message) { + Console.print(message); + }, +}; + +export default OutputView;