diff --git a/README.md b/README.md index b168a180..002d7465 100644 --- a/README.md +++ b/README.md @@ -1 +1,50 @@ # javascript-planetlotto-precourse + +## Functional requirements + +### 간단한 로또 발매기를 구현한다. + +- 로또 번호의 숫자 범위는 1~30까지이다. +- 1개의 로또를 발행할 때 중복되지 않는56개의 숫자를 뽑는다. +- 당첨 번호 추첨 시 중복되지 않는 숫자 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원 +- 로또 구입 금액을 입력하면 구입 금액에 해당하는 만큼 로또를 발행해야 한다. +- 로또 1장의 가격은 500원이다. +- 당첨 번호와 보너스 번호를 입력받는다. +- 사용자가 구매한 로또 번호와 당첨 번호를 비교하여 당첨 내역을 출력하고 로또 게임을 종료한다. +- 사용자가 잘못된 값을 입력할 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시키고 해당 메시지를 출력한 다음 해당 지점부터 다시 입력을 받는다. + +### 프로그래밍 요구 사항 + +- 프로그램 종료 시 process.exit()를 호출하지 않는다. +- 프로그래밍 요구 사항에서 달리 명시하지 않는 한 파일, 패키지 등의 이름을 바꾸거나 이동하지 않는다. +- 자바스크립트 코드 컨벤션을 지키면서 프로그래밍한다. +- 기본적으로 JavaScript Style Guide를 원칙으로 한다. +- 기본으로 제공되는 테스트가 통과해야 한다. + +### 도전 과제 +기본 요구 사항을 모두 충족한 후, 아래 중 하나를 선택하여 도전하세요. 단, 도전 과제 수행 여부와 관계없이 기본 기능은 반드시 작동해야 합니다. + +도전 방향 +- 리팩터링: 작동은 그대로 유지하면서 코드 품질을 높이는 방향 +- 기능 확장: 기본 기능 위에 새로운 기능을 추가하는 방향 +💡 어떤 도전을 선택할지 고민된다면 프리코스 1~3주 차와 오픈 미션을 돌아보세요. + +아쉬웠던 점은 무엇인가요? +- 다음에는 다르게 해보고 싶었던 것은 무엇인가요? +- 피드백을 받았지만 적용하지 못한 것은 무엇인가요? +정해진 정답은 없습니다. 본인의 프리코스 경험을 바탕으로 의미 있는 도전을 설계하세요. + +--- + +[문제 설계](docs/DESIGN.md) +[TDD](docs/TDD_SOLVE.md) +[트러블 슈팅](docs/TROBLE_SHOOTING.md) +[트러블 슈팅](docs/Feature.md) + +--- \ No newline at end of file diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js index 5b25d52d..d81f7d2c 100644 --- a/__tests__/ApplicationTest.js +++ b/__tests__/ApplicationTest.js @@ -67,6 +67,7 @@ describe("로또 테스트", () => { "[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개", @@ -80,7 +81,40 @@ describe("로또 테스트", () => { }); }); - test("예외 테스트", async () => { - await runException("500j"); - }); + // describe("예외 테스트", () => { + // beforeEach(() => { + // jest.restoreAllMocks(); + // }); + + // mockQuestions(["1000", "1,2,3,4,5", "6"]); + + // test.each([ + // { + // name: "로또 번호의 숫자 범위는 1~30까지이다.", + // inputs: ["1000", "1,2,3,4,31", "6"], + // }, + // { + // name: "로또 번호에 중복된 숫자가 있으면 안된다.", + // inputs: ["1000", "1,2,3,4,4", "6"], + // }, + // { + // name: "구입 금액은 숫자로 입력해야 한다.", + // inputs: ["price", "1,2,3,4,5", "6"], + // }, + // { + // name: "로또 번호의 개수는 5개여야 한다.", + // inputs: ["1000", "1,2,3,4,4", "6"], + // }, + // ])("$name", async ({ inputs }) => { + // // given + // mockQuestions(inputs); + + // // when + // const app = new App(); + + // // then + // await expect(app.run()).rejects.toThrow("[ERROR]"); + // }); + // }); + }); diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 00000000..1cb36ad3 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,33 @@ +# 문제 설계 + +## 청사진 + +### 입력 +1. 손님이 로또 구입 금액을 알려준다. +2. 손님이 본인의 당첨 번호를 알려준다. +3. 손님이 본인의 보너스 번호를 알려준다. + + +### 과정 +1. 관리자는 구입 금액에 해당하는 만큼의 로또를 발행한다. +2. 관리자는 당첨 번호와 보너스 번호를 발행한 로또와 대조한다. + + +### 출력 +1. 입력받은 입력 값을 확인해준다. +2. 입력받은 입력 값의 당첨 내역을 보여준다. +3. 손님을 돌려보낸다. + +--- + + +## 상세 설계 + +### 입력 +1. 손님은 본인의 로또 번호를 명확하게 말해야한다. + - 손님이 이상하게 말하면 관리자는 다시 줄서기를 요구한다. + - 이는 에러 처리를 통해 프로그램을 종료하는 것으로 구현한다. +2. 관리자는 정확하게 로또를 발행하고 통계를 내야한다. + - 숫자 범위 1~30 + - 발행시 추첨 시 중복되지 않는 5개의 숫자를 뽑기 + 보너스 번호 1개 뽑기 + - 손님이 지불한 금액을 명확하게 확인하기 (장당 500원) \ No newline at end of file diff --git a/docs/Feature.md b/docs/Feature.md new file mode 100644 index 00000000..6b0823de --- /dev/null +++ b/docs/Feature.md @@ -0,0 +1,23 @@ +# 구현한 기능 +기본적으로 제안서에서 제안된 기능을 모두 구현하였습니다. +1. 구입금액을 입력받는 기능 +1. 구입한 금액을 토대로 로또를 발행 +1. 발행한 로또와 손님의 로또번호를 비교 +1. 통계 제시 + +# 도전 + +1. 객체를 최대한 분할하고, 상수값을 따로 관리하는 등의 유지보수가 최대한 가능하고 유연한 구조로 리펙토링 하려고 했습니다. + +2. 추가기능을 구현했습니다. 아래는 해당하는 목록입니다. + + +# 추가기능 + +### 그 자리에서 직접 계산해서 손님에게 당첨 금액을 보여준다. + 저의 입장에서 생각했을 떄, 수익률 같은 것도 있겠으나 손님 입장에서는 얼마를 벌었는지를 알려주는게 더 명확하다고 느껴졌습니다. 따라서, 마지막에 추가적으로 얼마를 벌었는지 관리자가 제시해줍니다. + 이때 당첨이 안되면 부정적인 어구가 아니라 다음 기회에 도전할 수 있도록 얘기해서 다시 올수 있도록 합니다. + +### 단위별로 돈을 받는 것은 이상하다. + 손님은 700원을 줄 수도 있는데, 이에 대한 처리가 안되어 있습니다. + 이에 Math.floor를 이용해서 돈을 거슬러 주도록 설계했습니다. \ No newline at end of file diff --git a/docs/TDD_SOLVE.md b/docs/TDD_SOLVE.md new file mode 100644 index 00000000..f0e754d1 --- /dev/null +++ b/docs/TDD_SOLVE.md @@ -0,0 +1,102 @@ +# Test Driven Development +## 테스트로 주도한 계발의 과정을 서술합니다. + +### 가장 중요한 것은 통계 결과이다. +해피케이스는 기본적으로 입력값을 신뢰한 결과이다. + +1. 주어진 기능의 해피케이스 확인 + + mockRandoms([ + [8, 11, 13, 21, 22], + [1, 3, 6, 14, 22], + ]); + mockQuestions(["1000", "1,2,3,4,5", "6"]); + +2. 이 해피 케이스에 대한 최소의 해결책 제시 + + - 손님의 input을 고정한다. + - 발행되는 로또를 상수로 고정한다. + - 통계 결과를 고정된 상수 값을 통해서 제시한다. + +*결과* +당첨 통계 +'---' +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개 + +--- + +3. 이제 실제 제시된 테스트 케이스와 동일하게 log가 나오도록 유도한다. + - 이때 output은 기존에 만들어져있는 view 클래스를 사용한다. + - 편의를 위해서 생략돼있던 '---' 를 통계 결과에 추가했다. + - 최소한의 결과가 해결되었다. + +*결과* +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개 + +--- + +### 테스트 케이스가 해결되었으므로 더 나은 구조로 개선을 시작한다. +4. 이제 Input을 입력받을 수 있는 구조로 개선한다. + - Input과 Output 모두 제시되어 있는 view 객체를 사용한다. + - 여기서 Input 값은 제약이 많으므로 경계값에 대해서 유의하면서 테스트 코드를 작성한다. + +*결과* +구입금액을 입력해 주세요. +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개 + +이제 예외 케이스에 대한 테스트 코드를 작성한다. + 1. 구입 금액은 숫자로 입력해야 한다. + 2. 로또 번호는 5개여야 한다. + 3. 로또 번호는 1부터 30 사이의 숫자여야 한다. + 4. 로또 번호에 중복된 숫자가 있으면 안된다. + +--- + +5. 이제 로또 발행을 진행한다. + +--- + +6. Jest를 사용한 예외 케이스 테스트가 없어졌다. 따라서 콘솔창에서 수동으로 예외케이스를 확인하려고 한다. + 또한 main에 몰려있던 app에 몰려있었던 코드들을 객체로 단위별로 분리하는 리펙토링을 진행한다. + Output도 직접 만들었던 Output이 아니라 빌트인 Output을 이용해서 재 구현한다. + +--- + +7. 검증 케이스를 추가한다. +기존에 있던 OutputView 객체에는 부족한 검증 케이스가 있다. 이를 추가 적용한다. + - 입력한 손님의 로또 번호 개수가 5개인지 + - 입력한 손님의 로또 번호가 중복은 없는지 + +--- \ No newline at end of file diff --git a/docs/TROBLE_SHOOTING.md b/docs/TROBLE_SHOOTING.md new file mode 100644 index 00000000..d620a247 --- /dev/null +++ b/docs/TROBLE_SHOOTING.md @@ -0,0 +1,23 @@ +## 트러블 슈팅 + +### Rank 계산 간 오류 + +**문제** + 여기서 일치하는 사람이 없는 경우에 대해서 기록되지 않았다. + + this.lottos.forEach((lotto) => { + const rank = this.getRank(lotto); + if (rank) ranks[rank] += 1; + }); + +**해결** + 요구사항에서 일치하지 않는 사람의 경우 인덱스는 0이다. + 이때 rank가 0이면 if 문에서는 false를 반환하므로 넘어가버린다. + 따라서 rank == 0 인 경우에도 true를 반환하도록 수정했다. + + this.lottos.forEach((lotto) => { + const rank = this.getRank(lotto); + ranks[rank] += 1; + }); + + 또한 Prize case에서도 FAIL: { count: 0, bonus: false, prize: 0 }, 을 추가하여 선언적인 검증 과정을 추가했다. \ 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..d298d27d 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,88 @@ +import { Console, Random } from '@woowacourse/mission-utils'; +import { InputView, OutputView } from './view.js' +import Lotto from './Lotto.js'; +import { SETTING, ERROR_MESSAGE,PRIZE } from './constants.js' +import { calculateTotalAmount } from './utils.js' + class App { - async run() {} + lottos = []; + winningNumbers = []; + bonusNumber = 0; + + async run() { + try { + const amount = await InputView.askAmount(); + this.publishLottos(amount); + + let showLotto = []; + this.lottos.forEach(lotto => { + showLotto.push(lotto.getNumbers()); + }); + OutputView.printPurchasedLottos(showLotto); + + // 손님의 로또 번호 입력 + this.winningNumbers = await InputView.askWinningLotto(); + if (this.winningNumbers.length !== SETTING.NUM_COUNT) { + throw new Error(`${ERROR_MESSAGE.INVALID_LENGTH}`); + } + const uniqueNumbers = new Set(this.winningNumbers); + if (uniqueNumbers.size !== this.winningNumbers.length) { + throw new Error(`${ERROR_MESSAGE.DUPLICATE_NUMBER}`); + } + + this.bonusNumber = await InputView.askBonusNumber(); + + // Output 출력 + const rankMap = new Map(); + const ranks = this.calculateRanks(); + for (let i =0; i<6 ; i++){ + rankMap.set(i, ranks[i]); + } + OutputView.printResult(rankMap); + + const totalAmount = calculateTotalAmount(ranks); + if(totalAmount == 0) Console.print("괜찮아요. 다음 기회를 노려봅시다!"); + else Console.print("축하드립니다. " + totalAmount + "원에 당첨돼셨습니다!"); + + } catch (error) { + OutputView.printErrorMessage(error.message); + } + } + + publishLottos(money) { + const count = money / SETTING.UNIT_PRICE; + + for (let i = 0; i < count; i++) { + const numbers = Random.pickUniqueNumbersInRange( + SETTING.MIN_NUM, + SETTING.MAX_NUM, + SETTING.NUM_COUNT + ); + this.lottos.push(new Lotto(numbers)); + } + } + + calculateRanks() { + const ranks = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; + this.lottos.forEach((lotto) => { + const rank = this.getRank(lotto); + ranks[rank] += 1; + }); + return ranks; + } + + getRank(lotto) { + const matchCount = lotto.countMatch(this.winningNumbers); + const hasBonus = lotto.hasBonus(this.bonusNumber); + + if (matchCount === PRIZE.FIRST.count) return 1; + if (matchCount === PRIZE.SECOND.count && hasBonus) return 2; + if (matchCount === PRIZE.THIRD.count) return 3; + if (matchCount === PRIZE.FOURTH.count && hasBonus) return 4; + if (matchCount === PRIZE.FIFTH.count && hasBonus) return 5; + if (matchCount === PRIZE.FAIL.count) return 0; + return 1000000; + } } export default App; diff --git a/src/Lotto.js b/src/Lotto.js new file mode 100644 index 00000000..f130b47b --- /dev/null +++ b/src/Lotto.js @@ -0,0 +1,36 @@ +import { ERROR_MESSAGE, SETTING } from './constants.js'; + +class Lotto { + numbers; + + constructor(numbers) { + this.validate(numbers); + this.numbers = numbers.sort((a, b) => a - b); + } + + validate(numbers) { + if (numbers.length !== SETTING.NUM_COUNT) { + throw new Error(`${ERROR_MESSAGE.PREFIX}${ERROR_MESSAGE.INVALID_LENGTH}`); + } + const uniqueNumbers = new Set(numbers); + if (uniqueNumbers.size !== numbers.length) { + throw new Error(`${ERROR_MESSAGE.PREFIX}${ERROR_MESSAGE.DUPLICATE_NUMBER}`); + } + } + + getNumbers() { + return this.numbers; + } + + // 당첨 번호와 비교하여 일치 개수 반환 + countMatch(winningNumbers) { + return this.numbers.filter((number) => winningNumbers.includes(number)).length; + } + + // 보너스 번호 포함 여부 반환 + hasBonus(bonusNumber) { + return this.numbers.includes(bonusNumber); + } +} + +export default Lotto; \ No newline at end of file diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 00000000..ca5af2e8 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,23 @@ +export const ERROR_MESSAGE = { + PREFIX: '[ERROR] ', + INVALID_AMOUNT: '구입 금액은 숫자로 입력해야 합니다.', + INVALID_LENGTH: '로또 번호는 5개여야 합니다.', + INVALID_RANGE: '로또 번호는 1부터 30 사이의 숫자여야 합니다.', + DUPLICATE_NUMBER: '로또 번호에 중복된 숫자가 있습니다.', +}; + +export const SETTING = { + MIN_NUM: 1, + MAX_NUM: 30, + NUM_COUNT: 5, + UNIT_PRICE: 500, +}; + +export const PRIZE = { + FIRST: { count: 5, bonus: false, prize: 100000000 }, + SECOND: { count: 4, bonus: true, prize: 10000000 }, + THIRD: { count: 4, bonus: true, prize: 15000000 }, + FOURTH: { count: 3, bonus: true, prize: 500000 }, + FIFTH: { count: 2, bonus: false, prize: 5000 }, + FAIL: { count: 0, bonus: false, prize: 0 }, +}; \ No newline at end of file diff --git a/src/statistic.js b/src/statistic.js new file mode 100644 index 00000000..2b85a491 --- /dev/null +++ b/src/statistic.js @@ -0,0 +1,25 @@ +class Statistic { + + calculateRanks(lottos) { + const ranks = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; + lottos.forEach((lotto) => { + const rank = this.getRank(lotto); + if (rank || rank == 0) ranks[rank] += 1; + }); + return ranks; + } + + getRank(lotto) { + const matchCount = lotto.countMatch(this.winningNumbers); + const hasBonus = lotto.hasBonus(this.bonusNumber); + + if (matchCount === PRIZE.FIRST.count) return 1; + if (matchCount === PRIZE.SECOND.count && hasBonus) return 2; + if (matchCount === PRIZE.THIRD.count) return 3; + if (matchCount === PRIZE.FOURTH.count && hasBonus) return 4; + if (matchCount === PRIZE.FIFTH.count && hasBonus) return 5; + return 0; + } +} + +export default Statistic; \ No newline at end of file diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 00000000..d7d94cde --- /dev/null +++ b/src/utils.js @@ -0,0 +1,12 @@ +import {PRIZE} from './constants.js' + +export const calculateTotalAmount = (ranks) => { + const totalPrize = + ranks[1] * PRIZE.FIRST.prize + + ranks[2] * PRIZE.SECOND.prize + + ranks[3] * PRIZE.THIRD.prize + + ranks[4] * PRIZE.FOURTH.prize + + ranks[5] * PRIZE.FIFTH.prize; + + return totalPrize; +} \ No newline at end of file diff --git a/src/view.js b/src/view.js index ae6afd9c..1d4350a8 100644 --- a/src/view.js +++ b/src/view.js @@ -6,7 +6,7 @@ const InputView = { */ async askAmount() { const input = await MissionUtils.Console.readLineAsync('구입금액을 입력해 주세요.\n'); - const num = parseInt(input, 10); + const num = Math.floor(input); if (Number.isNaN(num)) { throw new Error('구매금액은 숫자여야 합니다.'); }