diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000000..f02c46ca4c --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,28 @@ +module.exports = { + env: { + browser: true, + es2021: true, + }, + extends: ['airbnb-base', 'prettier', 'jest'], + overrides: [ + { + env: { + node: true, + jest: true, + }, + files: ['.eslintrc.{js,cjs}'], + parserOptions: { + sourceType: 'script', + }, + }, + ], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + 'max-depth': ['error', 2], + 'max-params': ['error', 3], + 'max-lines-per-function': ['error', { max: 15 }], + }, +}; diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000..9ad9a45f4a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": true, + "semi": true, + "useTabs": false, + "tabWidth": 2, + "trailingComma": "all", + "printWidth": 80, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "auto" +} diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js index 227bd03864..883276b283 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/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; }; @@ -28,8 +28,8 @@ const runException = async (input) => { // 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 RANDOM_NUMBERS_TO_END = [1, 2, 3, 4, 5, 6]; + 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.play(); // 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,8 +91,7 @@ describe("로또 테스트", () => { }); }); - test("예외 테스트", async () => { - await runException("1000j"); + test('예외 테스트', async () => { + await runException('1000j'); }); }); - diff --git a/__tests__/ComputerTest.js b/__tests__/ComputerTest.js new file mode 100644 index 0000000000..1e646d628a --- /dev/null +++ b/__tests__/ComputerTest.js @@ -0,0 +1,105 @@ +import { MissionUtils } from '@woowacourse/mission-utils'; +import Computer from '../src/Computer.js'; +import Lotto from '../src/Lotto.js'; + +const getLogSpy = () => { + const logSpy = jest.spyOn(MissionUtils.Console, 'print'); + logSpy.mockClear(); + return logSpy; +}; + +describe('컴퓨터 기능 테스트', () => { + test.each([ + { input: 1000, count: 1 }, + { input: 10000, count: 10 }, + ])('로또 발행 장수 테스트', async ({ input, count }) => { + const computer = new Computer(); + computer.purchaseAmount = input; + await computer.issueLottoForPurchaseAmount(); + + expect(computer.lottos.length).toBe(count); + }); + + test('로또 결과 테스트', () => { + const computer = new Computer(); + computer.lottos = [ + new Lotto([8, 21, 23, 41, 42, 43]), + new Lotto([3, 5, 11, 16, 32, 38]), + new Lotto([7, 11, 16, 35, 36, 44]), + new Lotto([1, 8, 11, 31, 41, 42]), + new Lotto([13, 14, 16, 38, 42, 45]), + new Lotto([7, 11, 30, 40, 42, 43]), + new Lotto([2, 13, 22, 32, 38, 45]), + new Lotto([1, 3, 5, 14, 22, 45]), + ]; + computer.winningNumbers = [1, 2, 3, 4, 5, 6]; + computer.bonusNumber = 7; + + expect(computer.getLottoResults()).toEqual([7, 0, 0, 0, 0, 1]); + }); + + test('로또 결과 출력 테스트', () => { + const computer = new Computer(); + + const mockGetLottoResults = (result) => { + computer.getLottoResults = jest.fn(); + computer.getLottoResults.mockReturnValueOnce(result); + }; + mockGetLottoResults([0, 1, 1, 0, 0, 3]); + const logSpy = getLogSpy(); + + computer.printLottoWinningStatistics(); + + const logs = [ + '당첨 통계', + '---', + '3개 일치 (5,000원) - 3개', + '4개 일치 (50,000원) - 0개', + '5개 일치 (1,500,000원) - 0개', + '5개 일치, 보너스 볼 일치 (30,000,000원) - 1개', + '6개 일치 (2,000,000,000원) - 1개', + ]; + + logs.forEach((log) => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }); + }); + + test.each([ + { input: [0, 0, 0, 0, 0, 1], expected: 5000 }, + { input: [0, 0, 0, 1, 1, 0], expected: 1550000 }, + { input: [0, 1, 0, 0, 1, 0], expected: 2000050000 }, + { input: [0, 0, 0, 0, 0, 2], expected: 10000 }, + { input: [0, 0, 1, 2, 1, 0], expected: 33050000 }, + { input: [0, 1, 1, 1, 1, 1], expected: 2031555000 }, + ])('총 상금 계산 테스트', ({ input, expected }) => { + const computer = new Computer(); + computer.result = input; + + expect(computer.getTotalWinnings()).toEqual(expected); + }); + + test.each([ + { input: 5000, expected: '총 수익률은 6.3%입니다.' }, + { input: 10000, expected: '총 수익률은 12.5%입니다.' }, + { input: 15000, expected: '총 수익률은 18.8%입니다.' }, + { input: 1500000, expected: '총 수익률은 1,875.0%입니다.' }, + { input: 30000000, expected: '총 수익률은 37,500.0%입니다.' }, + { input: 20000000000, expected: '총 수익률은 25,000,000.0%입니다.' }, + ])('총 수익률 출력 테스트', ({ input, expected }) => { + const computer = new Computer(); + + const mockGetTotalWinnings = (result) => { + computer.getTotalWinnings = jest.fn(); + computer.getTotalWinnings.mockReturnValueOnce(result); + }; + + mockGetTotalWinnings(input); + const logSpy = getLogSpy(); + + computer.purchaseAmount = 80000; + computer.printTotalRateOfReturn(); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(expected)); + }); +}); diff --git a/__tests__/ConverterTest.js b/__tests__/ConverterTest.js new file mode 100644 index 0000000000..f62a725ce0 --- /dev/null +++ b/__tests__/ConverterTest.js @@ -0,0 +1,31 @@ +import { NumberConverter, ArrayConverter } from '../src/utils/Converter.js'; +describe('숫자 변환 테스트', () => { + test.each([ + { input: 1000, expected: '1,000' }, + { input: 10000, expected: '10,000' }, + { input: 1000000, expected: '1,000,000' }, + ])('$input에 1000단위로 콤마를 찍으면, $expected가 반환된다.', ({ input, expected }) => { + expect(NumberConverter.splitIntoThreeDigitWithComma(input)).toBe(expected); + }); + + test.each([ + { input: 1000.0, expected: '1,000.0' }, + { input: 1000.12, expected: '1,000.1' }, + { input: 1000.16, expected: '1,000.2' }, + { input: 37500.0, expected: '37,500.0' }, + { input: 25000000.0, expected: '25,000,000.0' }, + ])('$input에 1000단위로 콤마를 찍으면, $expected가 반환된다.', ({ input, expected }) => { + expect(NumberConverter.splitIntoThreeDigitWithCommaContainingDecimalPoint(input, 1)).toBe( + expected, + ); + }); +}); + +describe('배열 문자열 변환 테스트', () => { + test.each([{ input: [1, 2, 3, 4, 5], expected: '[1, 2, 3, 4, 5]' }])( + '$input을 문자열로 변환하면, $expected가 반환된다.', + ({ input, expected }) => { + expect(ArrayConverter.convertArrayToString(input)).toBe(expected); + }, + ); +}); diff --git a/__tests__/InputTest.js b/__tests__/InputTest.js new file mode 100644 index 0000000000..d86b266e2c --- /dev/null +++ b/__tests__/InputTest.js @@ -0,0 +1,83 @@ +import InputValidator from '../src/InputValidator.js'; + +describe('입력 유효성 검증 테스트', () => { + describe('로또 구입 금액 유효성 테스트', () => { + test('빈 값일 경우 예외를 발생시킨다.', () => { + expect(() => InputValidator.checkPurchaseAmount('')).toThrow('[ERROR]'); + }); + + test.each(['abc', 'ㄱㄴㄷ'])('숫자가 아닐 경우 예외를 발생시킨다.', (input) => { + expect(() => InputValidator.checkPurchaseAmount(input)).toThrow('[ERROR]'); + }); + + test.each(['-1000', '0'])('0 이하의 숫자일 경우 예외를 발생시킨다.', (input) => { + expect(() => InputValidator.checkPurchaseAmount(input)).toThrow('[ERROR]'); + }); + + test.each(['1', '100', '1234'])( + '1000으로 나누어떨어지지 않는 숫자일 경우 예외를 발생시킨다.', + (input) => { + expect(() => InputValidator.checkPurchaseAmount(input)).toThrow('[ERROR]'); + }, + ); + + test.each(['1000', '10000'])('값이 올바를 경우 예외를 발생시키지 않는다.', (input) => { + expect(() => InputValidator.checkPurchaseAmount(input)).not.toThrow('[ERROR]'); + }); + }); + + describe('당첨 번호 유효성 테스트', () => { + test('빈 값일 경우 예외를 발생시킨다.', () => { + expect(() => InputValidator.checkWinningNumbers('')).toThrow('[ERROR]'); + }); + + test.each(['abc,1,2,3,4,5', '1,2,3,4,5,ㄱㄴㄷ'])( + '숫자가 아닐 경우 예외를 발생시킨다.', + (input) => { + expect(() => InputValidator.checkWinningNumbers(input)).toThrow('[ERROR]'); + }, + ); + + test.each(['-1000,1,2,3,4,5', '1,2,3,4,5,0'])( + '0 이하의 숫자일 경우 예외를 발생시킨다.', + (input) => { + expect(() => InputValidator.checkWinningNumbers(input)).toThrow('[ERROR]'); + }, + ); + + test.each(['1,2,3,4', '1,2,3,4,5,6,7'])( + '숫자가 6개가 아닐 경우 예외를 발생시킨다.', + (input) => { + expect(() => InputValidator.checkWinningNumbers(input)).toThrow('[ERROR]'); + }, + ); + + test.each(['1,2,3,4,4,4', '1,1,1,1,1,1'])('숫자가 중복될 경우 예외를 발생시킨다.', (input) => { + expect(() => InputValidator.checkWinningNumbers(input)).toThrow('[ERROR]'); + }); + + test('값이 올바를 경우 예외를 발생시키지 않는다.', () => { + expect(() => InputValidator.checkWinningNumbers('1,2,3,4,5,6')).not.toThrow('[ERROR]'); + }); + }); + + describe('보너스 번호 유효성 테스트', () => { + const winningNumbers = [1, 2, 3, 4, 5, 6]; + + test('빈 값일 경우 예외를 발생시킨다.', () => { + expect(() => InputValidator.checkBonusNumber(winningNumbers, '')).toThrow('[ERROR]'); + }); + + test.each(['abc', 'ㄱㄴㄷ'])('숫자가 아닐 경우 예외를 발생시킨다.', (input) => { + expect(() => InputValidator.checkBonusNumber(winningNumbers, input)).toThrow('[ERROR]'); + }); + + test('당첨 번호에 존재하는 번호일 경우 예외를 발생시킨다.', () => { + expect(() => InputValidator.checkBonusNumber(winningNumbers, '6')).toThrow('[ERROR]'); + }); + + test('값이 올바를 경우 예외를 발생시키지 않는다.', () => { + expect(() => InputValidator.checkBonusNumber(winningNumbers, '7')).not.toThrow('[ERROR]'); + }); + }); +}); diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js index 97bd457659..dc993872a6 100644 --- a/__tests__/LottoTest.js +++ b/__tests__/LottoTest.js @@ -1,18 +1,110 @@ -import Lotto from "../src/Lotto.js"; +import Lotto from '../src/Lotto.js'; -describe("로또 클래스 테스트", () => { - test("로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.", () => { - expect(() => { - new Lotto([1, 2, 3, 4, 5, 6, 7]); - }).toThrow("[ERROR]"); +describe('로또 클래스 테스트', () => { + describe('로또 번호 입력', () => { + test('로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.', () => { + expect(() => { + new Lotto([1, 2, 3, 4, 5, 6, 7]); + }).toThrow('[ERROR]'); + }); + + test('로또 번호에 중복된 숫자가 있으면 예외가 발생한다.', () => { + expect(() => { + new Lotto([1, 2, 3, 4, 5, 5]); + }).toThrow('[ERROR]'); + }); + + test('로또 번호를 넣으면 번호가 오름차순으로 정렬된다.', () => { + const lotto = new Lotto([6, 5, 4, 3, 2, 1]); + expect(lotto.getNumbers()).toEqual([1, 2, 3, 4, 5, 6]); + }); }); - // TODO: 이 테스트가 통과할 수 있게 구현 코드 작성 - test("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.", () => { - expect(() => { - new Lotto([1, 2, 3, 4, 5, 5]); - }).toThrow("[ERROR]"); + describe('로또 일치하는 숫자', () => { + const winningNumbers = [1, 2, 3, 4, 5, 6]; + let cases = [ + { input: [7, 8, 9, 10, 11, 12], expected: 0 }, + { input: [1, 8, 9, 10, 11, 12], expected: 1 }, + { input: [1, 2, 9, 10, 11, 12], expected: 2 }, + { input: [1, 2, 3, 10, 11, 12], expected: 3 }, + { input: [1, 2, 3, 4, 11, 12], expected: 4 }, + { input: [1, 2, 3, 4, 5, 12], expected: 5 }, + { input: [1, 2, 3, 4, 5, 6], expected: 6 }, + ]; + + test.each(cases)( + '로또 당첨 번호가 $winningNumbers이고 로또가 $input이면, 일치하는 개수는 $expected이다.', + ({ input, expected }) => { + const lotto = new Lotto(input); + expect(lotto.getWinningNumbersMatchCount(winningNumbers)).toBe(expected); + }, + ); + + const bonusNumber = 10; + + cases = [ + [[7, 8, 9, 10, 11, 12]], + [[1, 8, 9, 10, 11, 12]], + [[1, 2, 9, 10, 11, 12]], + [[1, 2, 3, 10, 11, 12]], + ]; + + test.each(cases)( + '로또 보너스 번호가 $cases이고 로또 번호가 $bonusNumber이면, true를 반환한다.', + (input) => { + const lotto = new Lotto(input); + expect(lotto.hasBonusNumber(bonusNumber)).toBeTruthy(); + }, + ); + + cases = [[[1, 2, 3, 4, 11, 12]], [[1, 2, 3, 4, 5, 12]], [[1, 2, 3, 4, 5, 6]]]; + + test.each(cases)( + '로또 보너스 번호가 $cases이고 로또 번호가 $bonusNumber이면, false를 반환한다.', + (input) => { + const lotto = new Lotto(input); + expect(lotto.hasBonusNumber(bonusNumber)).toBeFalsy(); + }, + ); }); - // 아래에 추가 테스트 작성 가능 + const lotto = new Lotto([1, 2, 3, 4, 5, 6]); + + const mockGetkWinningNumbersMatchCount = (number) => { + lotto.getWinningNumbersMatchCount = jest.fn(); + lotto.getWinningNumbersMatchCount.mockReturnValueOnce(number); + }; + + const mockHasBonusNumber = (number) => { + lotto.hasBonusNumber = jest.fn(); + lotto.hasBonusNumber.mockReturnValueOnce(number); + }; + + describe('로또 등수 반환', () => { + let cases = [ + { matchCnt: 0, hasBonus: false, place: 0 }, + { matchCnt: 1, hasBonus: false, place: 0 }, + { matchCnt: 2, hasBonus: false, place: 0 }, + { matchCnt: 3, hasBonus: false, place: 5 }, + { matchCnt: 4, hasBonus: false, place: 4 }, + { matchCnt: 5, hasBonus: false, place: 3 }, + { matchCnt: 6, hasBonus: false, place: 1 }, + { matchCnt: 0, hasBonus: true, place: 0 }, + { matchCnt: 1, hasBonus: true, place: 0 }, + { matchCnt: 2, hasBonus: true, place: 0 }, + { matchCnt: 3, hasBonus: true, place: 5 }, + { matchCnt: 4, hasBonus: true, place: 4 }, + { matchCnt: 5, hasBonus: true, place: 2 }, + ]; + + test.each(cases)( + '로또 번호가 $matchCnt개 일치하고 보너스번호 유무가 $hasBonus이면, 등수는 $place이다.', + (input) => { + mockGetkWinningNumbersMatchCount(input.matchCnt); + mockHasBonusNumber(input.hasBonus); + + expect(lotto.getWinningPlace([1, 2, 3, 4, 5, 6], 7)).toBe(input.place); + }, + ); + }); }); diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000..7898f8523e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,50 @@ +# 로또 + +로또 게임을 진행한다. 숫자 범위는 1~45이다. 입력한 금액 만큼 로또를 발행한다. +사용자가 구매한 로또 번호와 당첨 번호를 비교하여 당첨 내역 및 수익률을 출력한다. + +### 기능 + +- 컴퓨터 + + - ✅랜덤으로 1~45 사이의 숫자 6개를 선택한다. + - ✅선택한 숫자 6개로 유저가 입력한 수만큼 로또를 발행한다. + - ✅발행한 로또 수량과 번호를 출력한다. 로또 번호는 오름차순이다. + - ✅당첨 내역을 출력한다. 숫자 일치 개수와 로또 개수의 쌍을 출력한다. + - ✅수익률을 출력한다. 소수점 둘째 자리에서 반올림한다. + - ✅예외 발생 시 에러 메세지를 출력한다. + +- 유저 + + - ✅로또 구입 금액을 입력한다. + - ✅잘못된 값을 입력한 경우 예외 발생, 에러 메세지를 출력하고 다시 입력받는다. + - ✅당첨 번호를 입력한다. + - ✅보너스 번호를 입력한다. + +- 로또 + + - ✅당첨 번호와 해당 로또 번호의 일치하는 숫자 개수를 계산한다. + - ✅로또 번호에 보너스 번호의 유무를 계산한다. + +### 예외 고려사항 + +- 로또 구입 금액 입력 + + - ✅빈 값을 입력할 경우 + - ✅숫자가 아닌 값을 입력할 경우 + - ✅0 이하의 숫자를 입력할 경우 + - ✅1000으로 나누어떨어지지 않는 숫자를 입력할 경우 + +- 당첨 번호 입력 + + - ✅빈 값을 입력할 경우 + - ✅숫자가 아닌 값을 입력할 경우 + - ✅1~45 이외의 숫자를 입력할 경우 + - ✅입력한 숫자가 6개가 아닐 경우 + - ✅같은 숫자가 여러 번 입력된 경우 + +- 보너스 번호 입력 + + - ✅빈 값을 입력할 경우 + - ✅숫자가 아닌 값을 입력할 경우 + - ✅당첨 번호에 있는 숫자를 입력할 경우 diff --git a/src/App.js b/src/App.js index c38b30d5b2..f5f11c2f6c 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,21 @@ +import Computer from './Computer.js'; + class App { - async play() {} + async play() { + const computer = new Computer(); + + await computer.getPurchaseAmountFromUserInput(); + computer.issueLottoForPurchaseAmount(); + computer.printLotto(); + + await computer.getWinningNumbersrFromUserInput(); + await computer.getBonusNumberFromUserInput(); + + computer.printLottoWinningStatistics(); + computer.printTotalRateOfReturn(); + + computer.resetLotto(); + } } export default App; diff --git a/src/Computer.js b/src/Computer.js new file mode 100644 index 0000000000..26613bbc62 --- /dev/null +++ b/src/Computer.js @@ -0,0 +1,82 @@ +import { Console } from '@woowacourse/mission-utils'; +import { MESSAGE, LOTTERY_WINNINGS } from './constants.js'; +import LottoManager from './LottoManager.js'; +import InputManager from './InputManager.js'; +import { NumberConverter, ArrayConverter } from './utils/converter.js'; + +export default class Computer { + constructor() { + this.resetLotto(); + } + + async getPurchaseAmountFromUserInput() { + this.purchaseAmount = await InputManager.getPurchaseAmount(); + } + + async getWinningNumbersrFromUserInput() { + this.winningNumbers = await InputManager.getWinningNumbers(); + } + + async getBonusNumberFromUserInput() { + this.bonusNumber = await InputManager.getBonusNumber(this.winningNumbers); + } + + issueLottoForPurchaseAmount() { + const lottoCnt = this.purchaseAmount / 1000; + Console.print(MESSAGE.PURCHASE_COUNT(lottoCnt)); + for (let count = 0; count < lottoCnt; count++) { + this.lottos.push(LottoManager.issueLotto()); + } + } + + printLotto() { + this.lottos.forEach((lotto) => + Console.print(ArrayConverter.convertArrayToString(lotto.getNumbers())), + ); + } + + printLottoWinningStatistics() { + this.result = this.getLottoResults(); + Console.print(MESSAGE.WINNING_STATISTICS); + Console.print(MESSAGE.DASHES); + for (let i = 5; i >= 1; i -= 1) { + const { label, winnings } = LOTTERY_WINNINGS[i]; + const commaWinnings = NumberConverter.splitIntoThreeDigitWithComma(winnings); + Console.print(MESSAGE.MATCH_LOTTO_COUNT(label, commaWinnings, this.result[i])); + } + } + + printTotalRateOfReturn() { + const totalWinnings = this.getTotalWinnings(); + const rateOfReturn = Number(((totalWinnings / this.purchaseAmount) * 100).toFixed(1)); + const commaRateOfReturn = NumberConverter.splitIntoThreeDigitWithCommaContainingDecimalPoint( + rateOfReturn, + 1, + ).toString(); + Console.print(MESSAGE.TOTAL_RATE_OF_RETURN(commaRateOfReturn)); + } + + getLottoResults() { + return this.lottos.reduce((result, lotto) => { + const newResult = [...result]; + const place = lotto.getWinningPlace(this.winningNumbers, this.bonusNumber); + newResult[place] += 1; + return newResult; + }, Array(6).fill(0)); + } + + getTotalWinnings() { + const winnings = LOTTERY_WINNINGS.map((place) => place.winnings); + return this.result.reduce((total, curCnt, place) => { + return total + curCnt * winnings[place]; + }, 0); + } + + resetLotto() { + this.purchaseAmount = 0; + this.bonusNumber = 0; + this.winningNumbers = []; + this.lottos = []; + this.result = []; + } +} diff --git a/src/InputManager.js b/src/InputManager.js new file mode 100644 index 0000000000..37483ca2e5 --- /dev/null +++ b/src/InputManager.js @@ -0,0 +1,42 @@ +import { Console } from '@woowacourse/mission-utils'; +import { MESSAGE } from './constants.js'; +import InputValidator from './InputValidator.js'; + +export default class InputManager { + static async getPurchaseAmount() { + const input = await Console.readLineAsync(MESSAGE.INPUT_PURCHASE_AMOUNT); + try { + InputValidator.checkPurchaseAmount(input); + return Number(input); + } catch (exception) { + this.printError(exception.message); + return this.getPurchaseAmount(); + } + } + + static async getWinningNumbers() { + const input = await Console.readLineAsync(MESSAGE.INPUT_WINNING_NUMBERS); + try { + InputValidator.checkWinningNumbers(input); + return input.split(',').map(Number); + } catch (exception) { + this.printError(exception.message); + return this.getWinningNumbers(); + } + } + + static async getBonusNumber(winningNumbers) { + const input = await Console.readLineAsync(MESSAGE.INPUT_BONUS_NUMBER); + try { + InputValidator.checkBonusNumber(winningNumbers, input); + return Number(input); + } catch (exception) { + this.printError(exception.message); + return this.getBonusNumber(winningNumbers); + } + } + + static printError(error) { + Console.print(error); + } +} diff --git a/src/InputValidator.js b/src/InputValidator.js new file mode 100644 index 0000000000..52ff49de2d --- /dev/null +++ b/src/InputValidator.js @@ -0,0 +1,33 @@ +import { ERROR, LOTTERY } from './constants.js'; +import { AlertError } from './utils/AlertError.js'; + +export default class InputValidation { + static checkPurchaseAmount(input) { + if (input.length === 0) throw new AlertError(ERROR.BLANK_INPUT); + const num = Number(input); + if (Number.isNaN(num)) throw new AlertError(ERROR.NOT_A_NUMBER); + if (num <= 0) throw new AlertError(ERROR.NOT_A_NATURAL_NUMBER); + if (num % LOTTERY.PRICE !== 0) throw new AlertError(ERROR.NOT_DIVIDED_BY_THOUSAND); + } + + static checkWinningNumbers(input) { + if (input.length === 0) throw new AlertError(ERROR.BLANK_INPUT); + const arr = input.split(',').map(Number); + if (arr.some((num) => Number.isNaN(num))) throw new AlertError(ERROR.NOT_A_NUMBER); + if ( + !arr.every((num) => { + return num >= LOTTERY.MIN_NUM && num <= LOTTERY.MAX_NUM; + }) + ) + throw new AlertError(ERROR.OUT_OF_RANGE(LOTTERY.MIN_NUM, LOTTERY.MAX_NUM)); + if (arr.length !== LOTTERY.NUM_COUNT) throw new AlertError(ERROR.NOT_SIX_NUMBERS); + if (new Set(arr).size !== LOTTERY.NUM_COUNT) throw new AlertError(ERROR.NOT_SIX_NUMBERS); + } + + static checkBonusNumber(winningNumbers, input) { + if (input.length === 0) throw new AlertError(ERROR.BLANK_INPUT); + const num = Number(input); + if (Number.isNaN(num)) throw new AlertError(ERROR.NOT_A_NUMBER); + if (winningNumbers.includes(num)) throw new AlertError(ERROR.ALREADY_SELECTED); + } +} diff --git a/src/Lotto.js b/src/Lotto.js index cb0b1527e9..a9ae4d30dd 100644 --- a/src/Lotto.js +++ b/src/Lotto.js @@ -1,18 +1,40 @@ -class Lotto { +import { AlertError } from './utils/AlertError.js'; +import { ERROR, LOTTERY } from './constants.js'; +export default class Lotto { #numbers; constructor(numbers) { this.#validate(numbers); - this.#numbers = numbers; + this.#numbers = numbers.sort((a, b) => a - b); } #validate(numbers) { - if (numbers.length !== 6) { - throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); + if (new Set(numbers).size !== LOTTERY.NUM_COUNT) { + throw new AlertError(ERROR.NOT_SIX_NUMBERS); } } - // TODO: 추가 기능 구현 -} + getNumbers() { + return this.#numbers; + } -export default Lotto; + getWinningNumbersMatchCount(winningNumbers) { + return this.#numbers.filter((num) => winningNumbers.includes(num)).length; + } + + hasBonusNumber(bonusNumber) { + return this.#numbers.includes(bonusNumber); + } + + getWinningPlace(winningNumbers, bonusNumber) { + const matchCnt = this.getWinningNumbersMatchCount(winningNumbers); + const hasBonus = this.hasBonusNumber(bonusNumber); + + if (matchCnt === LOTTERY.FIFTH_CNT) return LOTTERY.FIFTH_PLACE; + if (matchCnt === LOTTERY.FOURTH_CNT) return LOTTERY.FOURTH_PLACE; + if (matchCnt === LOTTERY.SECOND_CNT && hasBonus) return LOTTERY.SECOND_PLACE; + if (matchCnt === LOTTERY.THIRD_CNT) return LOTTERY.THIRD_PLACE; + if (matchCnt === LOTTERY.FIRST_CNT) return LOTTERY.FIRST_PLACE; + return LOTTERY.DEFAULT_PLACE; + } +} diff --git a/src/LottoManager.js b/src/LottoManager.js new file mode 100644 index 0000000000..cc9a3167c4 --- /dev/null +++ b/src/LottoManager.js @@ -0,0 +1,19 @@ +import Lotto from './Lotto.js'; +import { Console, Random } from '@woowacourse/mission-utils'; +import { LOTTERY } from './constants.js'; + +export default class LottoManager { + static issueLotto() { + const randNum = Random.pickUniqueNumbersInRange( + LOTTERY.MIN_NUM, + LOTTERY.MAX_NUM, + LOTTERY.NUM_COUNT, + ); + + try { + return new Lotto(randNum); + } catch (exception) { + Console.print(exception.message); + } + } +} diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000000..65f0599f1e --- /dev/null +++ b/src/constants.js @@ -0,0 +1,67 @@ +export const MESSAGE = Object.freeze({ + INPUT_PURCHASE_AMOUNT: '구입금액을 입력해 주세요.\n', + INPUT_WINNING_NUMBERS: '당첨 번호를 입력해 주세요.\n', + INPUT_BONUS_NUMBER: '보너스 번호를 입력해 주세요.\n', + WINNING_STATISTICS: '당첨 통계', + DASHES: '---', + PURCHASE_COUNT: (cnt) => `${cnt}개를 구매했습니다.`, + MATCH_NUMBERS: (cnt) => `${cnt}개 일치`, + MATCH_BONUS_NUMBER: '보너스 볼 일치', + MATCH_LOTTO_COUNT: (label, winnings, lottoCnt) => `${label} (${winnings}원) - ${lottoCnt}개`, + TOTAL_RATE_OF_RETURN: (total) => `총 수익률은 ${total}%입니다.`, +}); + +export const ERROR = Object.freeze({ + BLANK_INPUT: '입력값이 없습니다.', + NOT_A_NUMBER: '숫자여야 합니다.', + NOT_A_NATURAL_NUMBER: '1 이상의 숫자여야 합니다.', + NOT_DIVIDED_BY_THOUSAND: '숫자가 1000 단위여야 합니다.', + NOT_SIX_NUMBERS: '로또 번호는 6개여야 합니다.', + ALREADY_SELECTED: '당첨 번호에 존재하는 숫자입니다.', + OUT_OF_RANGE: (start, end) => `로또 번호는 ${start}부터 ${end} 사이의 숫자여야 합니다.`, +}); + +export const LOTTERY = Object.freeze({ + MIN_NUM: 1, + MAX_NUM: 45, + NUM_COUNT: 6, + PRICE: 1000, + FIRST_CNT: 6, + SECOND_CNT: 5, + THIRD_CNT: 5, + FOURTH_CNT: 4, + FIFTH_CNT: 3, + FIRST_PLACE: 1, + SECOND_PLACE: 2, + THIRD_PLACE: 3, + FOURTH_PLACE: 4, + FIFTH_PLACE: 5, + DEFAULT_PLACE: 0, +}); + +export const LOTTERY_WINNINGS = [ + Object.freeze({ + label: '', + winnings: 0, + }), + Object.freeze({ + label: '6개 일치', + winnings: 2000000000, + }), + Object.freeze({ + label: '5개 일치, 보너스 볼 일치', + winnings: 30000000, + }), + Object.freeze({ + label: '5개 일치', + winnings: 1500000, + }), + Object.freeze({ + label: '4개 일치', + winnings: 50000, + }), + Object.freeze({ + label: '3개 일치', + winnings: 5000, + }), +]; diff --git a/src/index.js b/src/index.js index 93eda3237a..0a3bc50015 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.play(); diff --git a/src/utils/AlertError.js b/src/utils/AlertError.js new file mode 100644 index 0000000000..c9751291a3 --- /dev/null +++ b/src/utils/AlertError.js @@ -0,0 +1,7 @@ +export class AlertError extends Error { + constructor(message) { + const newMessage = `[ERROR] ${message}`; + super(newMessage); + this.message = newMessage; + } +} diff --git a/src/utils/converter.js b/src/utils/converter.js new file mode 100644 index 0000000000..25d1ae37ac --- /dev/null +++ b/src/utils/converter.js @@ -0,0 +1,17 @@ +export class NumberConverter { + static splitIntoThreeDigitWithComma(inputNum) { + return inputNum.toLocaleString('ko-KR'); + } + static splitIntoThreeDigitWithCommaContainingDecimalPoint(inputNum, decimal) { + return inputNum.toLocaleString('ko-KR', { + minimumFractionDigits: decimal, + maximumFractionDigits: decimal, + }); + } +} + +export class ArrayConverter { + static convertArrayToString(arr) { + return `[${arr.join(', ')}]`; + } +}