diff --git a/README.md b/README.md index b168a180..547794cb 100644 --- a/README.md +++ b/README.md @@ -1 +1,221 @@ -# javascript-planetlotto-precourse +# 프리코스 최종 : 행성 로또 +## 📌 문제 소개 +우테코 로또 발매기인 행성 로또를 구현하는 것이며, 프리코스에서 학습한 문제 분해, 설계, TDD 등이 코드에 드러내도록 하는 것이 목적이다. + +#### 도전 과제 +기본 요구사항 충족 후, 아래 중 하나를 선택하여 도전하라 +- 리팩터링: 작동은 그대로 유지하면서 코드 품질을 높이는 방향 +- 기능 확장: 기본 기능 위에 새로운 기능을 추가하는 방향 + +이번 문제에서는 특히 입출력인 `view.js`는 기본 메서드를 수정/삭제가 불가하며 필요 시 추가는 가능하다. + +로또 : 1 ~ 30 + +로또 발행 시 -> 중복 X 5개 숫자 뽑기 + +당첨 번호 추첨 시 -> 중복 X 숫자 5개 뽑기 + 보너스 번호 1개 뽑기 + +당첨 기준 (1~5등) +- 1등 : 5개 번호 일치 => 100,000,000원 +- 2등 : 4개 번호 + 보너스 번호 일치 => 10,000,000원 +- 3등 : 4개 번호 일치 => 1,500,000원 +- 4등 : 3개 번호 일치 + 보너스 번호 일치 => 500,000원 +- 5등 : 2개 번호 일치 + 보너스 번호 일치 => 5,000원 + +로또 가격 : 500원/장 + +
+ +## 🚀 실행 방법 +```python +npm install +npm run start # 실행 +npm run test # 테스트 +``` +
+ +## ✨ 입출력 요구사항 +### 입력 +로또 구입 금액을 입력받는다. +``` +1000 +``` + +당첨 번호를 입력받는다. 번호는 쉼표(,)를 기준으로 구분한다. +``` +1,2,3,4,5 +``` + +보너스 번호를 입력받는다. +``` +6 +``` + +
+ +### 출력 +발행한 로또 수량 및 번호를 출력한다. 로또 번호는 오름차순으로 정렬하여 보여준다. +``` +2개를 구매했습니다. +[8, 11, 13, 21, 22] +[1, 3, 6, 14, 22] +``` + +당첨 내역을 출력한다. +``` +당첨 통계 +--- +5개 일치 (100,000,000원) - 0개 +4개 일치, 보너스 번호 일치 (10,000,000원) - 0개 +4개 일치 (1,500,000원) - 0개 +3개 일치, 보너스 번호 일치 (500,000원) - 0개 +2개 일치, 보너스 번호 일치 (5,000원) - 1개 +0개 일치 (0원) - 1개 +``` + +예외 상황 시 에러 문구를 출력해야 한다. 단, 에러 문구는 "[ERROR]"로 시작해야 한다. +``` +[ERROR] 로또 번호는 1부터 30 사이의 숫자여야 합니다. +``` + +### 실행 결과 예시 +``` +구입금액을 입력해 주세요. +1000 + +2개를 구매했습니다. +[8, 11, 13, 21, 22] +[1, 3, 6, 14, 22] + +당첨 번호를 입력해 주세요. +1, 2, 3, 4, 5 + +보너스 번호 번호를 입력해 주세요. +6 + +당첨 통계 +--- +5개 일치 (100,000,000원) - 0개 +4개 일치, 보너스 번호 일치 (10,000,000원) - 0개 +4개 일치 (1,500,000원) - 0개 +3개 일치, 보너스 번호 일치 (500,000원) - 0개 +2개 일치, 보너스 번호 일치 (5,000원) - 1개 +0개 일치 (0원) - 1개 +``` + +
+ +## 프로그래밍 요구사항 +- Node.js 22.19.0 버전에서 실행 가능해야 한다. +- 프로그램 실행의 시작점은 App.js의 run()이다. +- package.json 파일은 변경할 수 없으며, 제공된 라이브러리와 스타일 라이브러리 이외의 외부 라이브러리는 사용하지 않는다. +- 프로그램 종료 시 process.exit()를 호출하지 않는다. +- 프로그래밍 요구 사항에서 달리 명시하지 않는 한 파일, 패키지 등의 이름을 바꾸거나 이동하지 않는다. +- 자바스크립트 코드 컨벤션을 지키면서 프로그래밍한다. +- 기본적으로 JavaScript Style Guide를 원칙으로 한다. +- 기본으로 제공되는 테스트가 통과해야 한다. +- @woowacourse/mission-utils에서 제공하는 Random 및 Console API를 사용하여 구현해야 한다. + - Random 값 추출은 Random.pickUniqueNumbersInRange()를 활용한다. + - 사용자의 값을 입력 및 출력하려면 Console.readLineAsync()와 Console.print()를 활용한다. + +## ✅ 구현 체크리스트 + +#### 1단계: 로또 구입 금액 입력 받기 (InputView class) +- [ ] 숫자인지 체크 (ERROR) +- [ ] 정수인지 체크 (ERROR) +- [ ] 500원 이상인지 체크 (ERROR) +- [ ] 500원으로 나누어 떨어지는 지 체크 (ERROR) + +#### 2단계: 당첨 번호 입력 받기 (InputView class) +- [ ] SEPARATOR(,)가 있는지 체크 (ERROR) +- [ ] split된 배열 요소가 모두 숫자인지 체크 (ERROR) +- [ ] split된 배열 요소가 모두 정수인지 체크 (ERROR) +- [ ] split된 배열 요소가 모두 1~30인지 체크 (ERROR) +- [ ] split된 배열 요소 중 중복된 숫자가 있는지 체크 (ERROR) + +#### 3단계: 보너스 번호 입력 받기 (InputView class) +- [ ] 숫자인지 체크 (ERROR) +- [ ] 정수인지 체크 (ERROR) +- [ ] split된 당첨 번호 배열에 포함돼있는지 체크 (ERROR) + +#### 4단계: 로또 발행 기계 만들기 (LottoPublisher class) +- [ ] 4-1. 발행할 로또 개수 정하기 + - [ ] 로또 개수가 양의 정수인지 체크 (ERROR) + +- [ ] 4-2. 로또 번호 계산하기 + - [ ] 모든 번호가 1~30인지 체크 (ERROR) + - [ ] 중복된 번호가 있는지 체크 (ERROR) + +#### 5단계: 발행한 로또 개수 및 번호 출력하기 (OutputView class) +- [ ] 출력하기 + +#### 6단계: 당첨 통계 게산하기 (LottoCalculator class) +- [ ] 발행된 로또 번호마다 계산 + - [ ] 6-1. 발행 번호와 당첨번호 비교하여 일치하는 개수 계산 + - [ ] 정수인지 체크 (ERROR) + - [ ] 0~5인지 체크 (ERROR) + + - [ ] 6-2. 보너스 번호와 일치하는 지 계산 + - [ ] return 타입이 `booelan`인 지 체크 (ERROR) + + - [ ] 6-3. 등수 정하기 + +- [ ] 6-4. 전체 통계 계산하기 + +#### 7단계: 당첨 통계 출력하기 (OutputView class) +- [ ] 출력하기 + +
+ +## 🗂️ 아키텍처 +**MVC + shared** +``` +src/ +├── App.js (Controller) # 전체적인 흐름 제어 +├── services/ # 도메인 간 복잡한 흐름 제어 +├── domains/ # 비즈니스 로직 +├── views.js # 입출력 +└── shared/ # 공통 유틸 +``` + +
+ +## ❌ 에러 목록 +### 공통 + +
+ +### 입력 + +
+ +### 출력 + +
+ +### 도메인 + +
+ +## 🧪 테스트 +(도메인 별로 몇 개인지도 적기) +- **단위 테스트**: 30개 +- **통합 테스트**: 6개 (정상 2개, 예외 4개) +- **재입력 테스트**: 12개 + +
+ +## 도전 목록 +### 1. 에러 시 재입력받기 + +
+ + +## 📚 참고 자료 +[MissionUtils 라이브러리 분석](https://quirky-streetcar-a17.notion.site/mission-utils-28c523184d3c80d8904fe0870e5e4181) + +[우테코 Clean Code](https://github.com/woowacourse/woowacourse-docs/blob/main/cleancode/pr_checklist.md) + +[Commit Convention 정리](https://velog.io/@gaiogo2/Git-%EC%BB%A4%EB%B0%8B-%EC%BB%A8%EB%B2%A4%EC%85%98) + +[최종 테스트 대비 개인 참고 자료](https://quirky-streetcar-a17.notion.site/2e1523184d3c805f8e11da2dab1019c1) \ No newline at end of file diff --git a/__tests__/LottoPublisherTest.js b/__tests__/LottoPublisherTest.js new file mode 100644 index 00000000..a458fbcb --- /dev/null +++ b/__tests__/LottoPublisherTest.js @@ -0,0 +1,26 @@ +import { checkErrorMessage, LOTTO_ERROR } from "../src/shared/index.js"; +import { LottoPublisher } from "../src/domains/index.js"; + +describe("로또 발행기 클래스 테스트", () => { + describe("로또 벌향가 정상 테스트", () => { + test("1. 로또 발행이 정상적으로 작동한다.", () => { + expect(() => { + new LottoPublisher(1000); + }).not.toThrow(); + }); + }); + + describe("로또 예외 테스트", () => { + test("1. 로또 금액이 숫자가 아니다.", () => { + expect(() => { + new LottoPublisher('wrong str'); + }).toThrow(checkErrorMessage(LOTTO_ERROR.NON_NUMBER)); + }); + + test("2. 로또 금액이 양의 정수가 아니다.", () => { + expect(() => { + new LottoPublisher(500.2); + }).toThrow(checkErrorMessage(LOTTO_ERROR.NOT_POSITIVE_INTEGER)); + }); + }); +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 328e25a1..8b17a6cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2985,6 +2986,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001669", "electron-to-chromium": "^1.5.41", diff --git a/src/App.js b/src/App.js index 091aa0a5..07393b88 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,67 @@ +import { InputView, OutputView } from "./views/view.js"; +import { LottoPublisher, LottoCalculator, LOTTO_RANKING } from "./domains/index.js"; + class App { - async run() {} + async run() { + + + // 1. 로또 금액 입력받기 + const amount = await InputView.askAmount(); + OutputView.printSpace(); + + // 2. 당첨 번호 입력 받기 + const winningLotto = await InputView.askWinningLotto(); + OutputView.printSpace(); + + // 3. 보너스 번호 입력 받기 + const bonusNumber = await InputView.askBonusNumber(); + OutputView.printSpace(); + + // 4. 로또 발행 기계 만들기 + const lottoPublisher = new LottoPublisher(amount); + const lottoArray = lottoPublisher.getLottoArray(); + + // 5. 발행한 로또 및 개수 번호 출력하기 + OutputView.printPurchasedLottos(lottoArray); + + OutputView.printSpace(); + // 6. 당첨 통계 계산하기 + const lottoCalculator = new LottoCalculator(); + + for (let lotto of lottoArray) { + lottoCalculator.calculateRank(lotto, winningLotto, bonusNumber); + } + + const entries = Object.entries(LOTTO_RANKING); + + + const map = new Map(entries); + + let newMap = new Map(); + newMap.set(1, 0); + newMap.set(2, 0); + newMap.set(3, 0); + newMap.set(4, 0); + newMap.set(5, 0); + newMap.set(0, 0); + + const map1Value = map.get('ONE'); + const map2Value = map.get('TWO'); + const map3Value = map.get('THREE'); + const map4Value = map.get('FOUR'); + const map5Value = map.get('FIVE'); + const map0Value = map.get('ZERO'); + newMap.set(1, map1Value); + newMap.set(2, map2Value); + newMap.set(3, map3Value); + newMap.set(4, map4Value); + newMap.set(5, map5Value); + newMap.set(0, map0Value); + + + OutputView.printResult(newMap); + + } } export default App; diff --git a/src/domains/index.js b/src/domains/index.js new file mode 100644 index 00000000..cb505483 --- /dev/null +++ b/src/domains/index.js @@ -0,0 +1,5 @@ +export { LottoPublisherValidator } from './lottoPublisher/utils/LottoPublisherValidator.js'; +export { LottoPublisher } from './lottoPublisher/LottoPublisher.js'; + +export { LottoCalculator } from './lottoCalculator/LottoCalculator.js'; +export { LOTTO_RANKING } from './lottoCalculator/utils/LottoCalculatorConstants.js'; \ No newline at end of file diff --git a/src/domains/lottoCalculator/LottoCalculator.js b/src/domains/lottoCalculator/LottoCalculator.js new file mode 100644 index 00000000..d9f3e0f8 --- /dev/null +++ b/src/domains/lottoCalculator/LottoCalculator.js @@ -0,0 +1,40 @@ +// import { LOTTO } from "../../shared/index.js"; +import { LOTTO_RANKING } from "./utils/LottoCalculatorConstants.js"; + +class LottoCalculator { + + constructor() {} + + calculateRank(lotto, winningLotto, bonusNumber) { + const matchingNumbersLength = this.#calculateWinningNumbersLength(lotto, winningLotto); + const bonusNumberMatching = this.#calculateBonusNumberMatching(lotto, bonusNumber); + + if (matchingNumbersLength === 5) { LOTTO_RANKING['ONE']++; } + if (matchingNumbersLength === 4 && bonusNumberMatching) { LOTTO_RANKING['TWO']++; } + if (matchingNumbersLength === 4) { LOTTO_RANKING['THREE']++; } + if (matchingNumbersLength === 3 && bonusNumberMatching) { LOTTO_RANKING['FOUR']++; } + if (matchingNumbersLength === 2 && bonusNumberMatching) { LOTTO_RANKING['FIVE']++; } + if (matchingNumbersLength === 0) { LOTTO_RANKING['ZERO']++; } + } + + + getWinningNumbersLength(lotto, winningLotto) { + return this.#calculateWinningNumbersLength(lotto, winningLotto); + } + + #calculateBonusNumberMatching(lotto, bonusNumber) { + return lotto.includes(bonusNumber); + } + + #calculateWinningNumbersLength(lotto, winningLotto) { + /** + * @param {number[]} lotto + * @param {number[]} winningLotto + */ + const matchingNumbers = lotto.filter(lottoNumber => winningLotto.includes(lottoNumber)); + + return matchingNumbers.length; + } +} + +export { LottoCalculator }; \ No newline at end of file diff --git a/src/domains/lottoCalculator/utils/LottoCalculatorConstants.js b/src/domains/lottoCalculator/utils/LottoCalculatorConstants.js new file mode 100644 index 00000000..e5085327 --- /dev/null +++ b/src/domains/lottoCalculator/utils/LottoCalculatorConstants.js @@ -0,0 +1,10 @@ +let LOTTO_RANKING = { + 'ONE' : 0, + 'TWO' : 0, + 'THREE' : 0, + 'FOUR' : 0, + 'FIVE' : 0, + 'ZERO' : 0 +}; + +export { LOTTO_RANKING }; \ No newline at end of file diff --git a/src/domains/lottoPublisher/LottoPublisher.js b/src/domains/lottoPublisher/LottoPublisher.js new file mode 100644 index 00000000..baf53d5a --- /dev/null +++ b/src/domains/lottoPublisher/LottoPublisher.js @@ -0,0 +1,64 @@ +import { MissionUtils } from "@woowacourse/mission-utils"; +import { LOTTO_PUBLISHER } from "./utils/LottoPublisherConstants.js"; +import { LottoValidator, LOTTO } from "../../shared/index.js"; +import { LottoPublisherValidator } from "./utils/LottoPublisherValidator.js"; + +class LottoPublisher { + #amount; + #lottoArray; + #lottoCounts; + + constructor(amount) { + this.#validateAmount(amount); + + this.#amount = amount; + this.#lottoCounts = this.#calculateLottoCounts(amount); + this.#lottoArray = this.#calculateLottoNumberOnAmount(); + + this.#validateNumArrays(this.#lottoArray); + } + + #validateAmount(amount) { + LottoValidator.isPositiveInteger(amount); + } + + #validateNumArrays(lottoArray) { + LottoPublisherValidator.isValidNumArray(lottoArray); + } + + getLottoCounts() { + return this.#lottoCounts; + } + + #calculateLottoNumberOnAmount() { + let tmpArr = []; + + for (let i = 0; i < this.#lottoCounts; i++) { + let lottoNumber = this.#publishLottoNumber(); + tmpArr.push(lottoNumber); + } + + return tmpArr; + } + + #calculateLottoCounts(amount) { + return parseInt(amount / LOTTO_PUBLISHER.ONE_TICKET_PRICE); + } + + getLottoArray() { + return this.#lottoArray; + } + + #publishLottoNumber() { + return MissionUtils.Random.pickUniqueNumbersInRange( + LOTTO.START_NUMBER, LOTTO.END_NUMBER, LOTTO.COUNTS + ); + } + + getLottoArray() { + return this.#lottoArray; + } + +} + +export { LottoPublisher }; \ No newline at end of file diff --git a/src/domains/lottoPublisher/utils/LottoPublisherConstants.js b/src/domains/lottoPublisher/utils/LottoPublisherConstants.js new file mode 100644 index 00000000..75890dda --- /dev/null +++ b/src/domains/lottoPublisher/utils/LottoPublisherConstants.js @@ -0,0 +1,5 @@ +const LOTTO_PUBLISHER = Object.freeze({ + 'ONE_TICKET_PRICE' : 500 +}); + +export { LOTTO_PUBLISHER }; \ No newline at end of file diff --git a/src/domains/lottoPublisher/utils/LottoPublisherValidator.js b/src/domains/lottoPublisher/utils/LottoPublisherValidator.js new file mode 100644 index 00000000..4a28aa46 --- /dev/null +++ b/src/domains/lottoPublisher/utils/LottoPublisherValidator.js @@ -0,0 +1,18 @@ +import { LottoValidator } from "../../../shared/index.js"; + +class LottoPublisherValidator { + #numArrays; + + constructor(numArray) { + this.#numArrays = numArray; + } + + static isValidNumArray(numArrays) { + numArrays.forEach(numArray => { + LottoValidator.isNonvalidLottoNumberExists(numArray); + LottoValidator.isDuplicatedLottoNumberExists(numArray); + }) + } +} + +export { LottoPublisherValidator }; \ No newline at end of file diff --git a/src/shared/error/ErrorConstants.js b/src/shared/error/ErrorConstants.js new file mode 100644 index 00000000..63af3fb8 --- /dev/null +++ b/src/shared/error/ErrorConstants.js @@ -0,0 +1,3 @@ +const ERROR_PREFIX = "[ERROR]"; + +export { ERROR_PREFIX }; \ No newline at end of file diff --git a/src/shared/error/WoowaError.js b/src/shared/error/WoowaError.js new file mode 100644 index 00000000..c1112550 --- /dev/null +++ b/src/shared/error/WoowaError.js @@ -0,0 +1,11 @@ +import { ERROR_PREFIX } from "./ErrorConstants.js"; + +class WoowaError extends Error { + + constructor(errorMessage) { + super(`${ERROR_PREFIX} ${errorMessage}`); + this.name = "WoowaError"; + } +} + +export { WoowaError }; \ No newline at end of file diff --git a/src/shared/index.js b/src/shared/index.js new file mode 100644 index 00000000..9ffb5780 --- /dev/null +++ b/src/shared/index.js @@ -0,0 +1,7 @@ +export { WoowaError } from './error/WoowaError.js'; +export { ERROR_PREFIX } from './error/ErrorConstants.js'; + +export { LottoValidator } from './lotto/LottoValidator.js'; +export { LOTTO, LOTTO_ERROR } from './lotto/LottoConstants.js'; + +export { checkErrorMessage } from './test/checkErrorMessage.js'; \ No newline at end of file diff --git a/src/shared/lotto/LottoConstants.js b/src/shared/lotto/LottoConstants.js new file mode 100644 index 00000000..0129693d --- /dev/null +++ b/src/shared/lotto/LottoConstants.js @@ -0,0 +1,14 @@ +const LOTTO = Object.freeze({ + 'COUNTS' : 5, + 'START_NUMBER' : 1, + 'END_NUMBER' : 30 +}); + +const LOTTO_ERROR = Object.freeze({ + 'NON_NUMBER' : 'Not number', + 'NOT_POSITIVE_INTEGER' : 'Not positive interger', + 'NOT_RANGE_IN_LOTTO_NUMBER' : 'Lotto number has to be between 1 and 30', + 'DUPLICATED_LOTTO_NUMBER' : 'Duplicated number exists' +}); + +export { LOTTO, LOTTO_ERROR }; \ No newline at end of file diff --git a/src/shared/lotto/LottoValidator.js b/src/shared/lotto/LottoValidator.js new file mode 100644 index 00000000..2aad78c1 --- /dev/null +++ b/src/shared/lotto/LottoValidator.js @@ -0,0 +1,38 @@ +import { LOTTO, LOTTO_ERROR } from "./LottoConstants.js"; +import { WoowaError } from "../index.js"; + +class LottoValidator { + + constructor() {} + + static isNumber(num) { + if (Number.isNaN(num)) { + throw new WoowaError(LOTTO_ERROR.NON_NUMBER); + } + } + + static isPositiveInteger(num) { + if (!Number.isInteger(num) || num <= 0) { + throw new WoowaError(LOTTO_ERROR.NOT_POSITIVE_INTEGER); + } + } + + static isNonvalidLottoNumberExists(numArray) { + return numArray.forEach(num => this.isValidLottoNumber(num)); + } + + + static isValidLottoNumber(num) { + if (num < LOTTO.START_NUMBER && num > LOTTO.START_NUMBER) { + throw new WoowaError(LOTTO_ERROR.NOT_RANGE_IN_LOTTO_NUMBER); + } + } + + static isDuplicatedLottoNumberExists(numArray) { + if (numArray.length !== new Set(numArray).size) { + throw new WoowaError(LOTTO_ERROR.DUPLICATED_LOTTO_NUMBER); + } + } +} + +export { LottoValidator }; \ No newline at end of file diff --git a/src/shared/test/checkErrorMessage.js b/src/shared/test/checkErrorMessage.js new file mode 100644 index 00000000..3fb14ca6 --- /dev/null +++ b/src/shared/test/checkErrorMessage.js @@ -0,0 +1,5 @@ +const checkErrorMessage = (errorMessage) => { + return new RegExp(`^\\[ERROR\\] ${errorMessage}$`); +}; + +export { checkErrorMessage }; \ No newline at end of file diff --git a/src/view.js b/src/views/view.js similarity index 97% rename from src/view.js rename to src/views/view.js index ae6afd9c..cedbeaca 100644 --- a/src/view.js +++ b/src/views/view.js @@ -85,6 +85,10 @@ const OutputView = { printErrorMessage(message) { MissionUtils.Console.print(`[ERROR] ${message}`); }, + + printSpace() { + MissionUtils.Console.print(''); + } }; export { InputView, OutputView };