diff --git a/README.md b/README.md index 15bb106b5..0be14bc57 100644 --- a/README.md +++ b/README.md @@ -1 +1,232 @@ # javascript-lotto-precourse + +## 프리코스 3주차 - 로또 + +### 과제 진행 요구 사항 + +- 미션은 로또 저장소를 포크하고 클론하는 것으로 시작한다. +- 기능을 구현하기 전 README.md에 구현할 기능 목록을 정리해 추가한다. +- Git의 커밋 단위는 앞 단계에서 README.md에 정리한 기능 목록 단위로 추가한다. + - [AngularJS Git Commit Message Conventions](https://gist.github.com/stephenparish/9941e89d80e2bc58a153)을 참고해 커밋 메시지를 작성한다. +- 자세한 과제 진행 방법은 프리코스 진행 가이드 문서를 참고한다. + +--- + +### 기능 요구 사항 + +간단한 로또 발매기를 구현한다. + +- 로또 번호의 숫자 범위는 1~45까지이다. +- 1개의 로또를 발행할 때 중복되지 않는 6개의 숫자를 뽑는다. +- 당첨 번호 추첨 시 중복되지 않는 숫자 6개와 보너스 번호 1개를 뽑는다. +- 당첨은 1등부터 5등까지 있다. 당첨 기준과 금액은 아래와 같다. + - 1등: 6개 번호 일치 / 2,000,000,000원 + - 2등: 5개 번호 + 보너스 번호 일치 / 30,000,000원 + - 3등: 5개 번호 일치 / 1,500,000원 + - 4등: 4개 번호 일치 / 50,000원 + - 5등: 3개 번호 일치 / 5,000원 +- 로또 구입 금액을 입력하면 구입 금액에 해당하는 만큼 로또를 발행해야 한다. +- 로또 1장의 가격은 1,000원이다. +- 당첨 번호와 보너스 번호를 입력받는다. +- 사용자가 구매한 로또 번호와 당첨 번호를 비교하여 당첨 내역 및 수익률을 출력하고 로또 게임을 종료한다. +- 사용자가 잘못된 값을 입력할 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시키고 해당 메시지를 출력한 다음 해당 지점부터 다시 입력을 받는다. + +--- + +### 입출력 요구 사항 + +### 입력 + +- 로또 구입 금액을 입력 받는다. 구입 금액은 1,000원 단위로 입력 받으며 1,000원으로 나누어 떨어지지 않는 경우 예외 처리한다. + +```bash +14000 +``` + +- 당첨번호를 입력 받는다. 번호는 쉼표(,)를 기준으로 구분한다. + +```bash +1,2,3,4,5,6 +``` + +- 보너스 번호를 입력 받는다. + +```bash +7 +``` + +### 출력 + +- 발행한 로또 수량 및 번호를 출력한다. 로또 번호는 오름차순으로 정렬하여 보여준다. + +```sql +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] +``` + +- 당첨 내역을 출력한다. + +```sql +3개 일치 (5,000원) - 1개 +4개 일치 (50,000원) - 0개 +5개 일치 (1,500,000원) - 0개 +5개 일치, 보너스 볼 일치 (30,000,000원) - 0개 +6개 일치 (2,000,000,000원) - 0개 +``` + +- 수익률은 소수점 둘째 자리에서 반올림한다. (ex. 100.0%, 51.5%, 1,000,000.0%) + +```sql +총 수익률은 62.5% 입니다. +``` + +- 예외 상황 시 에러 문구를 출력해야 한다. 단, 에러 문구는 "[ERROR]"로 시작해야 한다. + +```sql +[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다. +``` + +### 실행 결과 예시 + +```sql +구입금액을 입력해 주세요. +8000 + +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] + +당첨 번호를 입력해 주세요. +1,2,3,4,5,6 + +보너스 번호를 입력해 주세요. +7 + +## 당첨 통계 + +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%입니다. +``` + +### 기능 구현 목록 + +- [x] 로또 구입금액을 입력 받는다. + +- [x] 당첨 번호를 입력 받는다. + +- [x] 보너스 번호를 입력 받는다. + +- [x] 발행한 로또 수량 및 번호를 출력한다. + - [x] 오름차순으로 정렬하여 출력한다. + +- [x] 당첨 내역을 출력한다. + +- [x] 수익률은 소수점 둘째 자리에서 반올림하여 출력한다. + +--- + +### 예외 처리 목록 + +- [x] 공통 예외 처리 항목 + - [x] 사용자가 입력한 값이 숫자가 아닌 경우 + - [x] 양의 정수가 아닌 경우 + - [x] 로또 번호가 `1~45` 범위에 있지 않은 경우 + +- [x] 로또 구매 금액 예외 처리 항목 + - [x] 1,000원으로 나누어 떨어지지 않는 경우 + - [x] 음수를 입력한 경우 + - [x] 소수를 입력한 경우 + +- [x] 당첨 번호 예외 처리 항목 + - [x] 음수를 입력한 경우 + - [x] 소수를 입력한 경우 + - [x] 당첨 번호가 6개가 아닌 경우 + - [x] 당첨 번호가 중복되는 경우 + - [x] 당첨 번호에 구분자가 쉼표(`,`)가 아닌 경우 + - [x] 당첨 번호가 `1~45` 사이의 숫자가 아닌 경우 + +- [x] 보너스 번호 예외 처리 항목 + - [x] 음수를 입력한 경우 + - [x] 소수를 입력한 경우 + - [x] 당첨 번호와 보너스 번호가 중복 되는 경우 + - [x] 보너스 번호가 `1~45` 사이의 숫자가 아닌 경우 + +--- + +### 생성한 테스트 함수 목록 + +-LottoGenerator.test.js: 로또 번호가 정상적으로 생성되는지 테스트 + +SortLotto.test.js: 이차원 배열을 오름차순으로 정렬되는지 테스트 + +CommonValidator.test.js: 유효성 검사에 공통적으로 사용되는 함수 테스트 + +PurchaseCostValidator.test.js: 구매 금액과 관련된 유효성 검사 함수 테스트 + +WinningNumberValidator.test.js: 당첨 번호와 관련된 유효성 검사 함수 테스트 + +BonusNumberValidator.test.js: 보너스 번호와 관련된 유효성 검사 함수 테스트 + +### 어려웠던 점 + +기존 객체지향에 대해 깊게 알지 못했고, 그러다보니 실제로 사용하는데 어려움이 많이 있었다. + +하지만 하루종일 객체지향에 대해 찾아보고 디자인 패턴과 객체지향적 설계 원칙을 찾았다. + +그러다보니 어느샌가 코드를 쓰면서 이 부분은 어떻게 써야 하겠다가 감이 조금씩 오기 시작했다. + +### 파일 구조 + +```plain +javascript-lotto-8 +├─ .npmrc +├─ package-lock.json +├─ package.json +├─ README.md +├─ src +│ ├─ controller +│ │ ├─ App.js +│ │ └─ LottoController.js +│ ├─ index.js +│ ├─ model +│ │ ├─ CalculateFinalResults.js +│ │ ├─ Lotto.js +│ │ └─ LottoGnerator.js +│ ├─ utils +│ │ ├─ BonusNumberValidator.js +│ │ ├─ CommonValidator.js +│ │ ├─ Constants.js +│ │ ├─ PurchaseCostValidator.js +│ │ ├─ SortLotto.js +│ │ └─ WinningNumberValidator.js +│ └─ view +│ ├─ InputView.js +│ └─ OutputView.js +└─ __tests__ + ├─ ApplicationTest.js + ├─ BonusNumberValidator.test.js + ├─ CommonValidator.test.js + ├─ LottoGnerator.test.js + ├─ LottoTest.js + ├─ PurchaseCostValidator.test.js + ├─ SortLotto.test.js + └─ WinningNumberValidator.test.js + +``` diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js index 872380c9c..adae21381 100644 --- a/__tests__/ApplicationTest.js +++ b/__tests__/ApplicationTest.js @@ -1,5 +1,5 @@ -import App from "../src/App.js"; -import { MissionUtils } from "@woowacourse/mission-utils"; +import App from '../src/controller/App.js'; +import { MissionUtils } from '@woowacourse/mission-utils'; const mockQuestions = (inputs) => { MissionUtils.Console.readLineAsync = jest.fn(); @@ -19,7 +19,7 @@ const mockRandoms = (numbers) => { }; const getLogSpy = () => { - const logSpy = jest.spyOn(MissionUtils.Console, "print"); + const logSpy = jest.spyOn(MissionUtils.Console, 'print'); logSpy.mockClear(); return logSpy; }; @@ -29,7 +29,7 @@ const runException = async (input) => { 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 INPUT_NUMBERS_TO_END = ['1000', '1,2,3,4,5,6', '7']; mockRandoms([RANDOM_NUMBERS_TO_END]); mockQuestions([input, ...INPUT_NUMBERS_TO_END]); @@ -39,15 +39,15 @@ const runException = async (input) => { await app.run(); // then - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[ERROR]")); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[ERROR]')); }; -describe("로또 테스트", () => { +describe('로또 테스트', () => { beforeEach(() => { jest.restoreAllMocks(); }); - test("기능 테스트", async () => { + test('기능 테스트', async () => { // given const logSpy = getLogSpy(); @@ -61,7 +61,7 @@ describe("로또 테스트", () => { [2, 13, 22, 32, 38, 45], [1, 3, 5, 14, 22, 45], ]); - mockQuestions(["8000", "1,2,3,4,5,6", "7"]); + mockQuestions(['8000', '1,2,3,4,5,6', '7']); // when const app = new App(); @@ -69,21 +69,21 @@ describe("로또 테스트", () => { // 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%입니다.", + '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%입니다.', ]; logs.forEach((log) => { @@ -91,7 +91,7 @@ describe("로또 테스트", () => { }); }); - test("예외 테스트", async () => { - await runException("1000j"); + test('예외 테스트', async () => { + await runException('1000j'); }); }); diff --git a/__tests__/BonusNumberValidator.test.js b/__tests__/BonusNumberValidator.test.js new file mode 100644 index 000000000..373ce0bc8 --- /dev/null +++ b/__tests__/BonusNumberValidator.test.js @@ -0,0 +1,39 @@ +import validateBonusNumber from '../src/utils/BonusNumberValidator.js'; +import { ERROR_MESSAGE } from '../src/utils/Constants.js'; + +describe('BonusNumberValidator 테스트', () => { + const winningNumber = [1, 2, 3, 4, 5, 6]; + + test('보너스 번호가 숫자가 아닐 시 에러 발생 (from validateTypeNumber)', () => { + const input = 'a'; + expect(() => validateBonusNumber(input, winningNumber)).toThrow( + `${ERROR_MESSAGE.COMMON.TYPE}\n`, + ); + }); + + test('보너스 번호가 정수가 아닐 시 에러 발생 (from validateNumberIsInteger)', () => { + const input = 7.5; + expect(() => validateBonusNumber(input, winningNumber)).toThrow( + ERROR_MESSAGE.COMMON.TYPE, + ); + }); + + test('보너스 번호가 1~45 범위를 벗어날 시 에러 발생 (from validateBonusNumberRange)', () => { + const input = 46; + expect(() => validateBonusNumber(input, winningNumber)).toThrow( + ERROR_MESSAGE.COMMON.RANGE, + ); + }); + + test('보너스 번호가 당첨 번호와 중복될 시 에러 발생 (from validateWinningNumberAndBounusNumberDuplicate)', () => { + const input = 6; // winningNumber에 6이 포함되어 있음 + expect(() => validateBonusNumber(input, winningNumber)).toThrow( + ERROR_MESSAGE.BONUS_NUMBER.DUPLICATE, + ); + }); + + test('유효한 보너스 번호(7) 입력 시 에러가 발생하지 않음', () => { + const input = 7; + expect(() => validateBonusNumber(input, winningNumber)).not.toThrow(); + }); +}); diff --git a/__tests__/CommonValidator.test.js b/__tests__/CommonValidator.test.js new file mode 100644 index 000000000..28268f5d6 --- /dev/null +++ b/__tests__/CommonValidator.test.js @@ -0,0 +1,65 @@ +import { + validateTypeNumber, + validateInputBlank, + validateNumberIsInteger, + validateBonusNumberRange, +} from '../src/utils/CommonValidator.js'; +import { ERROR_MESSAGE } from '../src/utils/Constants.js'; + +describe('CommonValidator 테스트', () => { + describe('validateTypeNumber', () => { + test('입력 값이 숫자가 아닐 시 에러 발생', () => { + const input = '100j'; + expect(() => validateTypeNumber(input)).toThrow( + `${ERROR_MESSAGE.COMMON.TYPE}\n`, + ); + }); + + test('입력 값이 공백일 시 에러 발생', () => { + const input = ' '; + expect(() => validateInputBlank(input)).toThrow( + `${ERROR_MESSAGE.COMMON.TYPE}`, + ); + }); + + test('유효한 숫자 문자열 입력 시 에러가 발생하지 않음', () => { + const input = '1000'; + expect(() => validateTypeNumber(input)).not.toThrow(); + }); + }); + + describe('validateNumberIsInteger', () => { + test('입력 값이 정수가 아닐 시 (실수) 에러 발생', () => { + const input = 1000.5; + expect(() => validateNumberIsInteger(input)).toThrow( + ERROR_MESSAGE.COMMON.TYPE, + ); + }); + + test('유효한 정수 입력 시 에러가 발생하지 않음', () => { + const input = 1000; + expect(() => validateNumberIsInteger(input)).not.toThrow(); + }); + }); + + describe('validateBonusNumberRange', () => { + test('로또 번호가 1 미만일 시 에러 발생', () => { + const input = 0; + expect(() => validateBonusNumberRange(input)).toThrow( + ERROR_MESSAGE.COMMON.RANGE, + ); + }); + + test('로또 번호가 45 초과일 시 에러 발생', () => { + const input = 46; + expect(() => validateBonusNumberRange(input)).toThrow( + ERROR_MESSAGE.COMMON.RANGE, + ); + }); + + test('유효한 범위의 로또 번호 입력 시 에러가 발생하지 않음', () => { + const input = 45; + expect(() => validateBonusNumberRange(input)).not.toThrow(); + }); + }); +}); diff --git a/__tests__/LottoGnerator.test.js b/__tests__/LottoGnerator.test.js new file mode 100644 index 000000000..13bb90429 --- /dev/null +++ b/__tests__/LottoGnerator.test.js @@ -0,0 +1,67 @@ +import { MissionUtils } from '@woowacourse/mission-utils'; +import LottoNumberGnerator from '../src/model/LottoGenerator.js'; +import Lotto from '../src/model/Lotto.js'; + +const mockRandoms = (numbers) => { + MissionUtils.Random.pickUniqueNumbersInRange = jest.fn(); + numbers.reduce((acc, number) => { + return acc.mockReturnValueOnce(number); + }, MissionUtils.Random.pickUniqueNumbersInRange); +}; + +describe('로또 생성 함수 테스트', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + test('구매 수량만큼 Lotto 객체 배열을 생성해야 한다', () => { + // 1. given + const purchaseCount = 3; + + const mockedNumbers = [ + [1, 2, 3, 4, 5, 6], + [7, 8, 9, 10, 11, 12], + [13, 14, 15, 16, 17, 18], + ]; + + const expectedLottos = [ + new Lotto([1, 2, 3, 4, 5, 6]), + new Lotto([7, 8, 9, 10, 11, 12]), + new Lotto([13, 14, 15, 16, 17, 18]), + ]; + + mockRandoms(mockedNumbers); + + // 2. when + const result = LottoNumberGnerator(purchaseCount); + + // 3. then + expect(MissionUtils.Random.pickUniqueNumbersInRange).toHaveBeenCalledTimes( + purchaseCount, + ); + expect(result).toEqual(expectedLottos); + }); + + test('로또 번호가 정렬되어 생성되어야 한다', () => { + // 1. given + const purchaseCount = 2; + + const mockedNumbers = [ + [6, 5, 4, 3, 2, 1], + [12, 11, 10, 9, 8, 7], + ]; + + const expectedLottos = [ + new Lotto([1, 2, 3, 4, 5, 6]), + new Lotto([7, 8, 9, 10, 11, 12]), + ]; + + mockRandoms(mockedNumbers); + + // 2. when + const result = LottoNumberGnerator(purchaseCount); + + // 3. then + expect(result).toEqual(expectedLottos); + }); +}); diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js index 409aaf69b..68b32431d 100644 --- a/__tests__/LottoTest.js +++ b/__tests__/LottoTest.js @@ -1,18 +1,139 @@ -import Lotto from "../src/Lotto"; +import Lotto from '../src/model/Lotto.js'; +import { Console } from '@woowacourse/mission-utils'; -describe("로또 클래스 테스트", () => { - test("로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.", () => { +describe('로또 클래스 테스트', () => { + test('로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.', () => { expect(() => { new Lotto([1, 2, 3, 4, 5, 6, 7]); - }).toThrow("[ERROR]"); + }).toThrow('[ERROR]'); }); // TODO: 테스트가 통과하도록 프로덕션 코드 구현 - test("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.", () => { + test('로또 번호에 중복된 숫자가 있으면 예외가 발생한다.', () => { expect(() => { new Lotto([1, 2, 3, 4, 5, 5]); - }).toThrow("[ERROR]"); + }).toThrow('[ERROR]'); }); // TODO: 추가 기능 구현에 따른 테스트 코드 작성 + + jest.mock('../src/utils/Constants.js', () => ({ + RANK: { + MATCH_COUNT: { + FIRST: 6, + SECOND_OR_THIRD: 5, + FOURTH: 4, + FIFTH: 3, + }, + RANK: { + FIRST: 1, + SECOND: 2, + THIRD: 3, + FOURTH: 4, + FIFTH: 5, + }, + }, + })); + + // 의존성(Console) 모킹 + // Console.print가 실제로 터미널에 로그를 찍지 않도록 가짜 함수로 대체 + const mockPrint = jest.spyOn(Console, 'print'); + mockPrint.mockImplementation(() => {}); + + describe('Lotto 클래스 테스트', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('showFinalResult (등수 계산) 테스트', () => { + const winningNumbers = [1, 2, 3, 4, 5, 6]; + const bonusNumber = 7; + + test('6개 번호가 모두 일치하면 1등(1)을 반환한다', () => { + // given + const myLotto = new Lotto([1, 2, 3, 4, 5, 6]); + // when + const rank = myLotto.showFinalResult(winningNumbers, bonusNumber); + // then + expect(rank).toBe(1); + }); + + test('5개 번호와 보너스 번호가 일치하면 2등(2)을 반환한다', () => { + // given + const myLotto = new Lotto([1, 2, 3, 4, 5, 7]); + // when + const rank = myLotto.showFinalResult(winningNumbers, bonusNumber); + // then + expect(rank).toBe(2); + }); + + test('5개 번호만 일치하고 보너스 번호가 다르면 3등(3)을 반환한다', () => { + // given + const myLotto = new Lotto([1, 2, 3, 4, 5, 8]); + // when + const rank = myLotto.showFinalResult(winningNumbers, bonusNumber); + // then + expect(rank).toBe(3); + }); + + test('4개 번호가 일치하면 (보너스 여부 관계없이) 4등(4)을 반환한다', () => { + // given + const myLottoWithBonus = new Lotto([1, 2, 3, 4, 7, 8]); + const myLottoWithoutBonus = new Lotto([1, 2, 3, 4, 8, 9]); + // when + const rank1 = myLottoWithBonus.showFinalResult( + winningNumbers, + bonusNumber, + ); + const rank2 = myLottoWithoutBonus.showFinalResult( + winningNumbers, + bonusNumber, + ); + // then + expect(rank1).toBe(4); + expect(rank2).toBe(4); + }); + + test('3개 번호가 일치하면 (보너스 여부 관계없이) 5등(5)을 반환한다', () => { + // given + const myLotto = new Lotto([1, 2, 3, 8, 9, 10]); + // when + const rank = myLotto.showFinalResult(winningNumbers, bonusNumber); + // then + expect(rank).toBe(5); + }); + + test('2개 이하의 번호가 일치하면 0 (꽝)을 반환한다', () => { + // given + const myLotto2 = new Lotto([1, 2, 8, 9, 10, 11]); + const myLotto1 = new Lotto([1, 8, 9, 10, 11, 12]); + const myLotto0 = new Lotto([10, 11, 12, 13, 14, 15]); + // when + const rank2 = myLotto2.showFinalResult(winningNumbers, bonusNumber); + const rank1 = myLotto1.showFinalResult(winningNumbers, bonusNumber); + const rank0 = myLotto0.showFinalResult(winningNumbers, bonusNumber); + // then + expect(rank2).toBe(0); + expect(rank1).toBe(0); + expect(rank0).toBe(0); + }); + }); + + describe('printLottoNumbers (출력) 테스트', () => { + test('Lotto 번호를 형식에 맞게 출력해야 한다', () => { + // given + const numbers = [8, 21, 23, 41, 42, 43]; + const lotto = new Lotto(numbers); + + // when + lotto.printLottoNumbers(); + + // then + // 1번 호출되었는지 검증 + const expectedOutput = '[8, 21, 23, 41, 42, 43]'; + expect(mockPrint).toHaveBeenCalledTimes(1); + expect(mockPrint).toHaveBeenCalledWith(expectedOutput); + }); + }); + }); }); diff --git a/__tests__/PurchaseCostValidator.test.js b/__tests__/PurchaseCostValidator.test.js new file mode 100644 index 000000000..ebd033f18 --- /dev/null +++ b/__tests__/PurchaseCostValidator.test.js @@ -0,0 +1,37 @@ +import validatePurchaseCost from '../src/utils/PurchaseCostValidator.js'; +import { ERROR_MESSAGE } from '../src/utils/Constants.js'; + +describe('PurchaseCostValidator 테스트', () => { + test('구매 금액이 숫자가 아닐 시 에러 발생 (from validateTypeNumber)', () => { + const input = '1000j'; + expect(() => validatePurchaseCost(input)).toThrow( + `${ERROR_MESSAGE.COMMON.TYPE}\n`, + ); + }); + + test('구매 금액이 정수가 아닐 시 에러 발생 (from validateNumberIsInteger)', () => { + const input = 1000.5; + expect(() => validatePurchaseCost(input)).toThrow( + ERROR_MESSAGE.COMMON.TYPE, + ); + }); + + test('구매 금액이 양수가 아닐 시 (0) 에러 발생 (from validatePurchaseCostIsPositive)', () => { + const input = 0; + expect(() => validatePurchaseCost(input)).toThrow( + ERROR_MESSAGE.COMMON.TYPE, + ); + }); + + test('구매 금액이 1,000원 단위가 아닐 시 에러 발생 (from validatePurchaseCostUnit)', () => { + const input = 1500; + expect(() => validatePurchaseCost(input)).toThrow( + ERROR_MESSAGE.PURCHASE.UNIT, + ); + }); + + test('유효한 구매 금액(3000) 입력 시 에러가 발생하지 않음', () => { + const input = 3000; + expect(() => validatePurchaseCost(input)).not.toThrow(); + }); +}); diff --git a/__tests__/SortLotto.test.js b/__tests__/SortLotto.test.js new file mode 100644 index 000000000..65390416e --- /dev/null +++ b/__tests__/SortLotto.test.js @@ -0,0 +1,10 @@ +import sortLotto from '../src/utils/SortLotto.js'; + +test('SortLotto: 정렬되지 않은 1차원 배열이 오름차순으로 정렬되어야 한다.', () => { + const previousSortArray = [8, 5, 23, 7, 42, 9]; + + // 실행 + const afterSortArray = sortLotto(previousSortArray); + // then + expect(previousSortArray).toEqual(afterSortArray); +}); diff --git a/__tests__/WinningNumberValidator.test.js b/__tests__/WinningNumberValidator.test.js new file mode 100644 index 000000000..33731e75c --- /dev/null +++ b/__tests__/WinningNumberValidator.test.js @@ -0,0 +1,70 @@ +import { + validateWinningNumber, + validateWinningNumberSeparator, +} from '../src/utils/WinningNumberValidator.js'; +import { ERROR_MESSAGE } from '../src/utils/Constants.js'; + +describe('WinningNumberValidator 테스트', () => { + describe('validateWinningNumberSeparator', () => { + test('당첨 번호 구분자가 쉼표(,)가 아닌 문자(.) 포함 시 에러 발생', () => { + const input = '1,2,3.4,5,6'; + expect(() => validateWinningNumberSeparator(input)).toThrow( + ERROR_MESSAGE.WINNING_NUMBER.SEPARATOR, + ); + }); + + test('당첨 번호에 숫자와 쉼표 외의 문자(a) 포함 시 에러 발생', () => { + const input = '1,2,3,4,5,a'; + expect(() => validateWinningNumberSeparator(input)).toThrow( + ERROR_MESSAGE.WINNING_NUMBER.SEPARATOR, + ); + }); + + test('유효한 쉼표 구분자 문자열 입력 시 에러가 발생하지 않음', () => { + const input = '1,2,3,4,5,6'; + expect(() => validateWinningNumberSeparator(input)).not.toThrow(); + }); + }); + + describe('validateWinningNumber', () => { + test('배열에 NaN이 포함될 시 에러 발생 (from validateTypeNumberInStringArray)', () => { + const input = [1, 2, 3, 4, 5, NaN]; + expect(() => validateWinningNumber(input)).toThrow( + ERROR_MESSAGE.COMMON.TYPE, + ); + }); + + test('당첨 번호가 6개가 아닐 시 에러 발생 (from validateWinningNumberCount)', () => { + const input = [1, 2, 3, 4, 5]; + expect(() => validateWinningNumber(input)).toThrow( + ERROR_MESSAGE.WINNING_NUMBER.COUNT, + ); + }); + + test('당첨 번호가 중복될 시 에러 발생 (from validateWinningNumberDuplicate)', () => { + const input = [1, 2, 3, 4, 5, 5]; + expect(() => validateWinningNumber(input)).toThrow( + ERROR_MESSAGE.WINNING_NUMBER.DUPLICATE, + ); + }); + + test('당첨 번호 중 정수가 아닌 숫자가 있을 시 에러 발생 (from validateWinningNumberIsInteger)', () => { + const input = [1, 2, 3, 4, 5, 5.5]; + expect(() => validateWinningNumber(input)).toThrow( + ERROR_MESSAGE.COMMON.RANGE, + ); + }); + + test('당첨 번호가 1~45 범위를 벗어날 시 에러 발생 (from validateWinningNumberInRange)', () => { + const input = [1, 2, 3, 4, 5, 46]; + expect(() => validateWinningNumber(input)).toThrow( + ERROR_MESSAGE.COMMON.RANGE, + ); + }); + + test('유효한 당첨 번호 배열 입력 시 에러가 발생하지 않음', () => { + const input = [1, 2, 3, 4, 5, 6]; + expect(() => validateWinningNumber(input)).not.toThrow(); + }); + }); +}); diff --git a/src/App.js b/src/App.js deleted file mode 100644 index 091aa0a5d..000000000 --- a/src/App.js +++ /dev/null @@ -1,5 +0,0 @@ -class App { - async run() {} -} - -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/controller/App.js b/src/controller/App.js new file mode 100644 index 000000000..30a0039b7 --- /dev/null +++ b/src/controller/App.js @@ -0,0 +1,10 @@ +import LottoController from './LottoController.js'; + +class App { + async run() { + const lottoController = new LottoController(); + await lottoController.start(); + } +} + +export default App; diff --git a/src/controller/LottoController.js b/src/controller/LottoController.js new file mode 100644 index 000000000..5ec3b7c0e --- /dev/null +++ b/src/controller/LottoController.js @@ -0,0 +1,36 @@ +import * as InputLotto from '../view/InputView.js'; +import LottoNumberGnerator from '../model/LottoGenerator.js'; +import { LOTTO_CALCULATE_NUMBER } from '../utils/Constants.js'; +import { calculatorFinalResults } from '../model/CalculateFinalResults.js'; +import { + printLottoTicket, + printProfitRate, + printPurchaseCount, + printStatistics, +} from '../view/OutputView.js'; + +class LottoController { + async start() { + const purchaseCost = await InputLotto.getLottoMoney(); + const purchaseCount = purchaseCost / LOTTO_CALCULATE_NUMBER.PER_LOTTO_PRICE; + const lottoObjects = LottoNumberGnerator(purchaseCount); + + printPurchaseCount(purchaseCount); + printLottoTicket(lottoObjects); + + const winningNumber = await InputLotto.getCorrectNumber(); + const bonusNumber = await InputLotto.getBonusNumber(winningNumber); + + const finalResult = calculatorFinalResults( + lottoObjects, + winningNumber, + bonusNumber, + purchaseCost, + ); + + printStatistics(finalResult.statistics); + printProfitRate(finalResult.profitRate); + } +} + +export default LottoController; diff --git a/src/index.js b/src/index.js index 02a1d389e..9f1977d76 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,4 @@ -import App from "./App.js"; +import App from './controller/App.js'; const app = new App(); await app.run(); diff --git a/src/model/CalculateFinalResults.js b/src/model/CalculateFinalResults.js new file mode 100644 index 000000000..6c151950c --- /dev/null +++ b/src/model/CalculateFinalResults.js @@ -0,0 +1,69 @@ +import { RANKING_INFO } from '../utils/Constants.js'; +import sortLotto from '../utils/SortLotto.js'; + +/** + * 등수가 담겨있는 배열을 각 등수 별로 몇개인지 세어 새로운 배열로 반환하는 함수 + * @param {number[]} ranksArray - 각 로또 한장의 등수가 담겨있는 배열 + * @returns {number[]} - 1~5등이 몇개인지 순서대로 담겨있는 배열 + */ +export function countRanks(ranksArray) { + const initialArray = [0, 0, 0, 0, 0, 0]; + + const countsArray = ranksArray.reduce((acc, currentNum) => { + if (currentNum >= 1 && currentNum <= 5) acc[currentNum] += 1; + return acc; + }, initialArray); + return countsArray; +} + +/** + * 로또 상금을 계산하는 함수 + * @param {number[]} lottoWinnerArray - 1~5등이 몇개인지 순서대로 담겨있는 배열 + * @returns {number} - 총 상금 + */ +export function calculateLottoPrizeMoney(lottoWinnerArray) { + const totalPrize = RANKING_INFO.reduce((sum, rankInfo) => { + const count = lottoWinnerArray[rankInfo.index]; + + return sum + count * rankInfo.prize; + }, 0); + + return totalPrize; +} + +/** + * 총 수익률을 계산하는 함수 + * @param {number} purchseCost - 구매 비용 + * @param {number[]} ranksArray - 각 로또 한장의 등수가 담겨있는 배열 + */ +export function calculateProfitRate(purchseCost, lottoWinnerArray) { + const winRate = calculateLottoPrizeMoney(lottoWinnerArray); + const returnRate = (winRate / purchseCost) * 100; + const roundedRate = Math.round(returnRate * 10) / 10; + + return roundedRate; +} + +/** + * 각 등별로 당첨된 로또 개수와 수익률을 계산하는 함수 + * @param {Lotto} lottoObjects - 로또 객체가 담겨있는 배열 + * @param {number[]} winningNumber - 당첨 번호 + * @param {number[]} bonusNumber - 보너스 번호 + * @param {number} purchaseCost - 로또 구매 비용 + * @returns {number[],number} - 각 로또 한장의 등수가 담겨있는 배열 + */ +export function calculatorFinalResults( + lottoObjects, + winningNumber, + bonusNumber, + purchaseCost, +) { + const lottoRankArray = lottoObjects.map((lotto) => + lotto.showFinalResult(winningNumber, bonusNumber), + ); + const sortedLottoRankArray = sortLotto(lottoRankArray); + const statistics = countRanks(sortedLottoRankArray); + const profitRate = calculateProfitRate(purchaseCost, statistics); + + return { statistics, profitRate }; +} diff --git a/src/model/Lotto.js b/src/model/Lotto.js new file mode 100644 index 000000000..d1a5297fe --- /dev/null +++ b/src/model/Lotto.js @@ -0,0 +1,89 @@ +import { Console } from '@woowacourse/mission-utils'; +import { RANK } from '../utils/Constants.js'; + +class Lotto { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = numbers; + } + + // eslint-disable-next-line + #validate(numbers) { + if (numbers.length !== 6) { + throw new Error('[ERROR] 로또 번호는 6개여야 합니다.'); + } + const set = new Set(numbers); + if (numbers.length !== set.size) { + throw new Error('[ERROR] 로또 번호에 중복된 숫자가 있습니다.'); + } + } + + // TODO: 추가 기능 구현 + + // 생성자를 통해 설정된 로또 번호를 출력하는 함수 + printLottoNumbers() { + Console.print(`[${this.#numbers.join(', ')}]`); + } + + /** + * 각 로또 1장마다 몇개의 번호가 당첨됐는지 비교하는 함수 + * @param {number[]} correctNumbers - 로또 당첨 번호가 담긴 배열 + * @returns {number} - 당첨된 번호의 개수 + */ + #compareOverlappingNumbers(correctNumbers) { + const matchCount = this.#numbers.filter((ele) => + correctNumbers.includes(ele), + ).length; + + return matchCount; + } + + /** + * 로또 구매 번호에 보너스 번호 포함 여부만 계산하여 반환하는 함수 + * @param {number[]} bonusNumber - 로또 보너스 번호가 담긴 배열 + * @returns {boolean} + */ + #hasBonusNumber(bonusNumber) { + return this.#numbers.includes(bonusNumber); + } + + /** + * 당첨된 번호의 개수를 기반으로 등수를 산정하는 함수 + * @param {number[]} matchCount - 당첨된 번호의 개수가 담긴 배열 + * @param {boolean} bonusNumber - 보너스 번호 존재 여부 + * @returns - 로또 용지 별 당첨 번호 매칭 개수 + */ + #calculateRank(matchCount, bonusNumber) { + const hasBonusNumber = this.#hasBonusNumber(bonusNumber); + if (matchCount === RANK.MATCH_COUNT.FIRST) return RANK.RANK.FIRST; + + if (matchCount === RANK.MATCH_COUNT.SECOND_OR_THIRD && hasBonusNumber) + return RANK.RANK.SECOND; + + if (matchCount === RANK.MATCH_COUNT.SECOND_OR_THIRD && !hasBonusNumber) + return RANK.RANK.THIRD; + + if (matchCount === RANK.MATCH_COUNT.FOURTH) return RANK.RANK.FOURTH; + + if (matchCount === RANK.MATCH_COUNT.FIFTH) return RANK.RANK.FIFTH; + + return 0; + } + + /** + * 당첨 번호와 보너스 번호를 받아, 현재 로또 티켓의 최종 등수를 반환하는 함수 + * @param {number[]} correctNumbers - 당첨 번호 6개가 담긴 배열 + * @param {number[]} bonusNumber - 보너스 번호 1개가 담긴 배열 + * @returns {number} - 당첨 등수 + */ + showFinalResult(correctNumbers, bonusNumber) { + const matchCount = this.#compareOverlappingNumbers(correctNumbers); + + const result = this.#calculateRank(matchCount, bonusNumber); + + return result; + } +} +export default Lotto; diff --git a/src/model/LottoGenerator.js b/src/model/LottoGenerator.js new file mode 100644 index 000000000..f1b1204cd --- /dev/null +++ b/src/model/LottoGenerator.js @@ -0,0 +1,30 @@ +import { Random } from '@woowacourse/mission-utils'; +import sortLotto from '../utils/SortLotto.js'; +import Lotto from './Lotto.js'; + +/** + * 정렬된 6개의 로또 번호를 가진 Lotto 객체 1개를 생성하는 함수 + * @returns {Lotto} - Lotto 객체 1개 + */ +function createSingleLottoTicket() { + const lottoNumbers = Random.pickUniqueNumbersInRange(1, 45, 6); + + const sortedLottoNumbers = sortLotto(lottoNumbers); + + return new Lotto(sortedLottoNumbers); +} + +/** + * 주어진 로또 구매 수량에 따라 Lotto 객체 배열을 생성 후 반환하는 함수 + * @param {number} purchaseCount - 로또 구매 수량 + * @returns {Lotto[]} - 로또 객체 배열 + */ +export default function LottoNumberGnerator(purchaseCount) { + const totalLottoTickets = []; + + for (let i = 0; i < purchaseCount; i += 1) { + totalLottoTickets.push(createSingleLottoTicket()); + } + + return totalLottoTickets; +} diff --git a/src/utils/BonusNumberValidator.js b/src/utils/BonusNumberValidator.js new file mode 100644 index 000000000..c940f0cd0 --- /dev/null +++ b/src/utils/BonusNumberValidator.js @@ -0,0 +1,37 @@ +import { ERROR_MESSAGE } from './Constants.js'; +import { + validateTypeNumber, + validateNumberIsInteger, + validateBonusNumberRange, + validateInputBlank, +} from './CommonValidator.js'; + +/** + * 보너스 번호가 당첨 번호 배열에 이미 존재하는지 (중복되는지) 검사하는 함수 + * @param {number} bonusNumber - 보너스 번호 + * @param {number[]} winningNumber - 당첨 번호 배열 + */ +function validateWinningNumberAndBounusNumberDuplicate( + bonusNumber, + winningNumber, +) { + if (winningNumber.includes(Number(bonusNumber))) { + throw new Error(ERROR_MESSAGE.BONUS_NUMBER.DUPLICATE); + } +} + +/** + * 보너스 번호에 대한 유효성 검사를 진행하는 함수 + * - 보너스 번호가 숫자인지 검사 + * - 보너스 번호의 범위가 1~45인지 검사 + * - 당첨 번호와 보너스 번호가 중복되는 경우 + * @param {number} bonusNumber - 보너스 번호 + * @param {number[]} winningNumber - 당첨 번호가 담긴 배열 + */ +export default function validateBonusNumber(bonusNumber, winningNumber) { + validateInputBlank(bonusNumber); + validateTypeNumber(bonusNumber); + validateNumberIsInteger(bonusNumber); + validateBonusNumberRange(bonusNumber); + validateWinningNumberAndBounusNumberDuplicate(bonusNumber, winningNumber); +} diff --git a/src/utils/CommonValidator.js b/src/utils/CommonValidator.js new file mode 100644 index 000000000..235530d07 --- /dev/null +++ b/src/utils/CommonValidator.js @@ -0,0 +1,37 @@ +import { ERROR_MESSAGE } from './Constants.js'; + +export function validateInputBlank(input) { + if (!input || String(input).trim() === '') { + throw new Error(ERROR_MESSAGE.COMMON.TYPE); + } +} + +/** + * 입력값이 숫자인지 검사하는 함수 + * @param {string} value - 숫자 입력값 + */ +export function validateTypeNumber(value) { + if (Number.isNaN(Number(value))) { + throw new Error(`${ERROR_MESSAGE.COMMON.TYPE}\n`); + } +} + +/** + * 입력값이 정수인지 검사하는 함수 + * @param {number} value - 숫자 입력값 + */ +export function validateNumberIsInteger(value) { + if (!Number.isInteger(Number(value))) { + throw new Error(ERROR_MESSAGE.COMMON.TYPE); + } +} + +/** + * 로또 번호 1개가 1~45 범위 내에 있는지 검사하는 함수 + * @param {number} value - 로또 번호 + */ +export function validateBonusNumberRange(bonusNumber) { + if (bonusNumber <= 0 || bonusNumber > 45) { + throw new Error(ERROR_MESSAGE.COMMON.RANGE); + } +} diff --git a/src/utils/Constants.js b/src/utils/Constants.js new file mode 100644 index 000000000..ccf38198d --- /dev/null +++ b/src/utils/Constants.js @@ -0,0 +1,99 @@ +export const CONSOLE_MESSAGE = { + PURCHASE_MONEY: '구입금액을 입력해 주세요.', + PURCHASE_AMOUNT: '개를 구매했습니다.', + CORRECT_NUMBER: '당첨 번호를 입력해 주세요.', + BONUS_NUMBER: '보너스 번호를 입력해 주세요.', +}; + +export const WINNING_STATISTICS_MESSAGE = { + WINNING_STATISTICS: '당첨 통계\n---', + AMOUNT: '개', + MATCH_THREE: '3개 일치 (5,000원) - ', + MATCH_FOUR: '4개 일치 (50,000원) - ', + MATCH_FIVE_NO_BONUS: '5개 일치 (1,500,000원) - ', + MATCH_FIVE_BONUS: '5개 일치, 보너스 볼 일치 (30,000,000원) - ', + MATCH_SIX: '6개 일치 (2,000,000,000원) - ', +}; + +export const LOTTO_CALCULATE_NUMBER = { + PER_LOTTO_PRICE: 1000, + FIRST_WIN_LOTTO: 2000000000, + SECOND_WIN_LOTTO: 30000000, + THIRD_WIN_LOTTO: 1500000, + FOURTH_WIN_LOTTO: 50000, + FIFTH_WIN_LOTTO: 5000, +}; + +export const RANK = { + MATCH_COUNT: { + FIRST: 6, + SECOND_OR_THIRD: 5, + FOURTH: 4, + FIFTH: 3, + }, + RANK: { + FIRST: 1, + SECOND: 2, + THIRD: 3, + FOURTH: 4, + FIFTH: 5, + NONE: 0, + }, +}; + +export const RANKING_INFO = [ + { + rank: 5, + index: 5, + message: `${WINNING_STATISTICS_MESSAGE.MATCH_THREE}`, + prize: `${LOTTO_CALCULATE_NUMBER.FIFTH_WIN_LOTTO}`, + }, + { + rank: 4, + index: 4, + message: `${WINNING_STATISTICS_MESSAGE.MATCH_FOUR}`, + prize: `${LOTTO_CALCULATE_NUMBER.FOURTH_WIN_LOTTO}`, + }, + { + rank: 3, + index: 3, + message: `${WINNING_STATISTICS_MESSAGE.MATCH_FIVE_NO_BONUS}`, + prize: `${LOTTO_CALCULATE_NUMBER.THIRD_WIN_LOTTO}`, + }, + { + rank: 2, + index: 2, + message: `${WINNING_STATISTICS_MESSAGE.MATCH_FIVE_BONUS}`, + prize: `${LOTTO_CALCULATE_NUMBER.SECOND_WIN_LOTTO}`, + }, + { + rank: 1, + index: 1, + message: `${WINNING_STATISTICS_MESSAGE.MATCH_SIX}`, + prize: `${LOTTO_CALCULATE_NUMBER.FIRST_WIN_LOTTO}`, + }, +]; + +export const ERROR_MESSAGE = { + PURCHASE: { + // 구매 금액 관련 + UNIT: '[ERROR] 로또 구매 금액은 1,000원 단위로만 가능합니다.', + RANGE: '[ERROR] 로또 구매 금액은 양의 정수만 가능합니다.', + }, + WINNING_NUMBER: { + // 당첨 번호 관련 + COUNT: '[ERROR] 당첨 번호가 6개만 입력 가능합니다.', + DUPLICATE: '[ERROR] 당첨 번호는 중복이 불가능합니다.', + SEPARATOR: '[ERROR] 쉼표 외에 구분자는 사용할 수 없습니다.', + }, + BONUS_NUMBER: { + // 보너스 번호 관련 + COUNT: '[ERROR] 보너스 번호는 1개만 입력 가능합니다.', + DUPLICATE: '[ERROR] 당첨 번호와 보너스 번호는 중복이 불가능합니다.', + }, + COMMON: { + // 공통 에러 + TYPE: '[ERROR] 양의 정수만 입력 가능합니다.', + RANGE: '[ERROR] 1~45의 양의 정수만 입력 가능합니다.', + }, +}; diff --git a/src/utils/PurchaseCostValidator.js b/src/utils/PurchaseCostValidator.js new file mode 100644 index 000000000..85c8daf79 --- /dev/null +++ b/src/utils/PurchaseCostValidator.js @@ -0,0 +1,42 @@ +import { ERROR_MESSAGE } from './Constants.js'; +import { + validateTypeNumber, + validateNumberIsInteger, + validateInputBlank, +} from './CommonValidator.js'; + +/** + * 로또 구매 금액이 양수인지 검사하는 함수 + * @param {number} purchaseCost - 로또 구매 금액 + */ +function validatePurchaseCostIsPositive(purchaseCost) { + if (purchaseCost <= 0) { + throw new Error(ERROR_MESSAGE.COMMON.TYPE); + } +} + +/** + * 로또 구매 금액이 1,000원 단위인지 검사하는 함수 + * @param {number} purchaseCost - 로또 구매 금액 + */ +function validatePurchaseCostUnit(purchaseCost) { + if (purchaseCost % 1000 !== 0) { + throw new Error(ERROR_MESSAGE.PURCHASE.UNIT); + } +} + +/** + * 로또 구매 금액에 대한 유효성 검사를 진행하는 함수 + * - 숫자가 입력되지 않은 경우 + * - 정수가 아닌 경우 + * - 양수가 아닌 경우 + * - 1,000 단위가 아닌 경우 + * @param {number} purchaseCost - 로또 구매 금액 + */ +export default function validatePurchaseCost(purchaseCost) { + validateInputBlank(purchaseCost); + validateTypeNumber(purchaseCost); + validateNumberIsInteger(purchaseCost); + validatePurchaseCostIsPositive(purchaseCost); + validatePurchaseCostUnit(purchaseCost); +} diff --git a/src/utils/SortLotto.js b/src/utils/SortLotto.js new file mode 100644 index 000000000..0eedb4161 --- /dev/null +++ b/src/utils/SortLotto.js @@ -0,0 +1,8 @@ +/** + * 중첩 배열을 오름차순으로 정렬하는 함수 + * @param {number[]} lottoNumberArray - 로또 번호가 저장된 배열 + * @returns {number[]} - 오름차순으로 정렬된 배열 + */ +export default function sortLotto(lottoNumberArray) { + return lottoNumberArray.sort((a, b) => a - b); +} diff --git a/src/utils/WinningNumberValidator.js b/src/utils/WinningNumberValidator.js new file mode 100644 index 000000000..992022b05 --- /dev/null +++ b/src/utils/WinningNumberValidator.js @@ -0,0 +1,84 @@ +import { ERROR_MESSAGE } from './Constants.js'; + +/** + * 당첨 번호 배열의 모든 숫자가 1~45 범위 내에 있는지 검사하는 함수 + * @param {number[]} array - 당첨 번호 배열 + */ +function validateWinningNumberInRange(array) { + function compareRange(element) { + return element > 0 && element <= 45; + } + const result = array.every((val) => compareRange(val)); + if (!result) { + throw new Error(ERROR_MESSAGE.COMMON.RANGE); + } +} + +/** + * 배열의 모든 요소가 유효한 숫자인지(NaN이 아닌지) 검사하는 함수 + * @param {number[]} winningNumberArray - 숫자 배열 (NaN 포함 가능) + */ +function validateTypeNumberInStringArray(winningNumberArray) { + if (!winningNumberArray.every((val) => !Number.isNaN(val))) { + throw new Error(ERROR_MESSAGE.COMMON.TYPE); + } +} + +/** + * 당첨 번호 배열의 개수가 6개인지 검사하는 함수 + * @param {number[]} winningNumberArray - 당첨 번호 배열 + */ +function validateWinningNumberCount(winningNumberArray) { + if (winningNumberArray.length !== 6) { + throw new Error(ERROR_MESSAGE.WINNING_NUMBER.COUNT); + } +} + +/** + * 당첨 번호 배열에 중복된 숫자가 있는지 검사하는 함수 + * @param {number[]} winningNumberArray - 당첨 번호 배열 + */ +function validateWinningNumberDuplicate(winningNumberArray) { + const uniqueNumbersArray = new Set(winningNumberArray); + if (winningNumberArray.length !== uniqueNumbersArray.size) { + throw new Error(ERROR_MESSAGE.WINNING_NUMBER.DUPLICATE); + } +} + +/** + * 당첨 번호 배열의 모든 숫자가 정수인지 검사하는 함수 + * @param {number[]} array - 당첨 번호 배열 + */ +function validateWinningNumberIsInteger(array) { + if (!array.every((ele) => Number.isInteger(ele))) { + throw new Error(ERROR_MESSAGE.COMMON.RANGE); + } +} + +/** + * 당첨 번호 입력 문자열이 숫자와 쉼표(,)로만 구성되어 있는지 검사하는 함수 + * @param {string} string - 당첨 번호 입력 원본 문자열 + */ +export function validateWinningNumberSeparator(string) { + const regex = /^[0-9,]+$/; + if (!regex.test(string)) { + throw new Error(ERROR_MESSAGE.WINNING_NUMBER.SEPARATOR); + } +} + +/** + * 당첨 번호에 대한 유효성 검사를 진행하는 함수 + * - 숫자가 입력되지 않은 경우 + * - 6개가 입력되지 않은 경우 + * - 중복된 숫자를 입력한 경우 + * - 당첨 번호가 1~45 사이의 범위인지 검사 + * - 구분자가 쉼표인지 검사 + * @param {number[]} winningNumberArray - 당첨 번호 배열 + */ +export function validateWinningNumber(winningNumberArray) { + validateTypeNumberInStringArray(winningNumberArray); + validateWinningNumberCount(winningNumberArray); + validateWinningNumberDuplicate(winningNumberArray); + validateWinningNumberIsInteger(winningNumberArray); + validateWinningNumberInRange(winningNumberArray); +} diff --git a/src/view/InputView.js b/src/view/InputView.js new file mode 100644 index 000000000..d70742059 --- /dev/null +++ b/src/view/InputView.js @@ -0,0 +1,94 @@ +import { Console } from '@woowacourse/mission-utils'; +import { CONSOLE_MESSAGE } from '../utils/Constants.js'; +import validatePurchaseCost from '../utils/PurchaseCostValidator.js'; +import { + validateWinningNumber, + validateWinningNumberSeparator, +} from '../utils/WinningNumberValidator.js'; +import validateBonusNumber from '../utils/BonusNumberValidator.js'; +import { validateInputBlank } from '../utils/CommonValidator.js'; + +/** + * 주어진 값을 배열로 변환하는 함수 + * @param {string} stringValue - 쉼표로 구분된 입력 문자열 + * @return {string[]} - 분리된 문자열이 담긴 배열 + */ +export function splitValue(stringValue) { + const afterSplitArray = stringValue.split(','); + + return afterSplitArray; +} + +/** + * 문자열 배열을 숫자 배열로 변환하는 함수 + * @param {string[]} stringArray - 문자열 배열 + * @returns {number[]} - 숫자형 배열 + */ +export function stringToInt(stringArray) { + const numberArray = stringArray.map(Number); + + return numberArray; +} + +/** + * 로또 구매 금액을 입력받고 숫자로 반환하는 함수 + * @returns {Promise} - 사용자가 입력한 구매 금액 + */ +export async function getLottoMoney() { + while (true) { + try { + const purchaseAmount = await Console.readLineAsync( + `${CONSOLE_MESSAGE.PURCHASE_MONEY}\n`, + ); + + validatePurchaseCost(purchaseAmount); + + return Number(purchaseAmount); + } catch (error) { + Console.print(error.message); + } + } +} + +/** + * 6개의 당첨 번호를 입력받고 배열로 반환하는 함수 + * @returns {Promise} - 쉼표로 구분하여 입력된 6개의 당첨 번호 (문자열 배열) + */ +export async function getCorrectNumber() { + while (true) { + try { + const winningNumber = await Console.readLineAsync( + `\n${CONSOLE_MESSAGE.CORRECT_NUMBER}\n`, + ); + validateInputBlank(winningNumber); + validateWinningNumberSeparator(winningNumber); + const splitWinningNumber = stringToInt(splitValue(winningNumber)); + + validateWinningNumber(splitWinningNumber); + + return splitWinningNumber; + } catch (error) { + Console.print(error.message); + } + } +} + +/** + * 1개의 보너스 번호를 입력받고 배열로 반환하는 함수 + * @returns {Promise} - 보너스 번호가 담긴 문자열 배열 + */ +export async function getBonusNumber(winningNumber) { + while (true) { + try { + const bonusNumber = await Console.readLineAsync( + `\n${CONSOLE_MESSAGE.BONUS_NUMBER}\n`, + ); + + validateBonusNumber(bonusNumber, winningNumber); + + return stringToInt(splitValue(bonusNumber)); + } catch (error) { + Console.print(error.message); + } + } +} diff --git a/src/view/OutputView.js b/src/view/OutputView.js new file mode 100644 index 000000000..10d0f3f41 --- /dev/null +++ b/src/view/OutputView.js @@ -0,0 +1,47 @@ +import { Console } from '@woowacourse/mission-utils'; +import { + CONSOLE_MESSAGE, + RANKING_INFO, + WINNING_STATISTICS_MESSAGE, +} from '../utils/Constants.js'; + +/** + * 로또 구매 수량을 출력하는 함수 + * @param {number} purchaseCount + */ +export function printPurchaseCount(purchaseCount) { + Console.print(`\n${purchaseCount}${CONSOLE_MESSAGE.PURCHASE_AMOUNT}`); +} + +/** + * 발행된 로또 번호를 출력하는 함수 + * @param {object} lottoObjects - 로또 번호가 담겨있는 객체 + */ +export function printLottoTicket(lottoObjects) { + for (let i = 0; i < lottoObjects.length; i += 1) { + lottoObjects[i].printLottoNumbers(); + } +} + +/** + * 당첨 통계를 출력하는 함수 + * @param {number[]} lottoWinnerArray - 각 로또 한장의 등수가 담겨있는 배열 + */ +export function printStatistics(lottoWinnerArray) { + Console.print(`\n${WINNING_STATISTICS_MESSAGE.WINNING_STATISTICS}`); + + RANKING_INFO.forEach((rankInfo) => { + const count = lottoWinnerArray[rankInfo.index]; + Console.print( + `${rankInfo.message}${count}${WINNING_STATISTICS_MESSAGE.AMOUNT}`, + ); + }); +} + +/** + * 총 수익률을 출력하는 함수 + * @param {number} profitRate + */ +export function printProfitRate(profitRate) { + Console.print(`총 수익률은 ${profitRate}%입니다.`); +}