diff --git a/__tests__/domain/HintTest.js b/__tests__/domain/HintTest.js new file mode 100644 index 000000000..757dbf750 --- /dev/null +++ b/__tests__/domain/HintTest.js @@ -0,0 +1,65 @@ +import Hint from '../../src/domain/Hint.js'; + +describe('Hint 클래스 테스트', () => { + describe('calcultaeStrikeCount 메서드는 numbers와 computerNumbers를 입력받아 스크라이크 개수를 반환한다.', () => { + const cases = [ + { numbers: [1, 2, 3], computerNumbers: [2, 3, 1], expected: 0 }, + { numbers: [1, 2, 3], computerNumbers: [1, 2, 3], expected: 3 }, + { numbers: [4, 2, 1], computerNumbers: [4, 1, 2], expected: 1 }, + ]; + + test.each(cases)( + '사용자의 번호 $numbers와 랜덤으로 생성된 번호 $computerNumbers가 주어지는 경우, calcultaeStrikeCount()는 개수 $expected를 반환한다.', + ({ numbers, computerNumbers, expected }) => { + // when + const hint = new Hint(numbers, computerNumbers); + const strikeCount = hint.calculateStrikeCount(); + // then + expect(strikeCount).toEqual(expected); + }, + ); + }); + + describe('calculateBallCount 메서드는 strikeCount를 입력받아 볼 개수를 반환한다.', () => { + const cases = [ + { numbers: [1, 2, 3], computerNumbers: [2, 3, 1], expected: 3 }, + { numbers: [1, 2, 3], computerNumbers: [1, 2, 3], expected: 0 }, + { numbers: [4, 2, 1], computerNumbers: [4, 1, 2], expected: 2 }, + ]; + + test.each(cases)( + '스트라이크 개수 $strikeCount가 주어지는 경우, calculateBallCount()는 개수 $expected를 반환한다.', + ({ numbers, computerNumbers, expected }) => { + // when + const hint = new Hint(numbers, computerNumbers); + const strikeCount = hint.calculateStrikeCount(); + const ballCount = hint.calculateBallCount(strikeCount); + + // then + expect(ballCount).toEqual(expected); + }, + ); + }); + + describe('generateHintMessage 메서드는 strikeCount와 ballCount를 입력받아 결과 메시지의 배열을 반환한다.', () => { + const cases = [ + { numbers: [1, 2, 3], computerNumbers: [2, 3, 1], expected: ['3볼'] }, + { numbers: [1, 2, 3], computerNumbers: [1, 2, 3], expected: ['3스트라이크'] }, + { numbers: [4, 2, 1], computerNumbers: [4, 1, 2], expected: ['2볼', '1스트라이크'] }, + ]; + + test.each(cases)( + '스트라이크 개수 $strikeCount와 볼의 개수 $ballCount가 주어지는 경우, generateHintMessage()는 결과 메시지를 배열 형태의 $expected로 반환한다.', + ({ numbers, computerNumbers, expected }) => { + // when + const hint = new Hint(numbers, computerNumbers); + const strikeCount = hint.calculateStrikeCount(); + const ballCount = hint.calculateBallCount(strikeCount); + const hintMessage = hint.generateHintMessage(strikeCount, ballCount); + + // then + expect(hintMessage).toEqual(expected); + }, + ); + }); +}); diff --git a/__tests__/validators/NumbersValidatorTest.js b/__tests__/validators/NumbersValidatorTest.js new file mode 100644 index 000000000..71efe9f2d --- /dev/null +++ b/__tests__/validators/NumbersValidatorTest.js @@ -0,0 +1,40 @@ +import ERROR from '../../src/constants/error.js'; +import NumbersValidator from '../../src/validators/NumbersValidator.js'; + +describe('숫자 입력 예외 상황 테스트', () => { + const cases = [ + { + input: '12', + description: '숫자가 3자리가 아닌 경우 예외처리를 한다.', + expected: ERROR.numbers.length, + }, + { + input: 'asd', + description: '숫자가 아닌 경우 예외처리를 한다.', + expected: ERROR.numbers.notANumber, + }, + { + input: '-123', + description: '숫자가 음수인 경우 예외처리를 한다.', + expected: ERROR.numbers.negative, + }, + { + input: '122', + description: '숫자가 중복된 경우 예외처리를 한다.', + expected: ERROR.numbers.duplicated, + }, + { + input: '', + description: '숫자를 입력하지 않을 경우 예외처리를 한다', + expected: ERROR.numbers.empty, + }, + ]; + + test.each(cases)('사용자 $input을 통해 에러를 반환한다.', ({ input, expected }) => { + // when + const result = () => NumbersValidator.validateNumbers(input); + + // then + expect(result).toThrow(expected); + }); +}); diff --git a/__tests__/validators/RestartValidatorTest.js b/__tests__/validators/RestartValidatorTest.js new file mode 100644 index 000000000..1656864f4 --- /dev/null +++ b/__tests__/validators/RestartValidatorTest.js @@ -0,0 +1,35 @@ +import ERROR from '../../src/constants/error.js'; +import RestartValidator from '../../src/validators/RestartValidator.js'; + +describe('게임 재시작 여부 입력 예외 상황 테스트', () => { + const cases = [ + { + input: '0', + description: '1, 2가 아닌 다른 값을 입력한 경우 예외처리를 한다.', + expected: ERROR.restart.choice, + }, + { + input: '-1', + description: '값이 음수일 경우 예외처리를 한다.', + expected: ERROR.restart.negative, + }, + { + input: 'asd', + description: '값이 숫자가 아닌 경우 예외처리를 한다.', + expected: ERROR.numbers.notANumber, + }, + { + input: '', + description: '값을 입력하지 않았을 경우 예외처리를 한다.', + expected: ERROR.restart.empty, + }, + ]; + + test.each(cases)('게임 재시작 여부인 $input을 통해 에러를 반환한다.', ({ input, expected }) => { + // when + const result = () => RestartValidator.validateRestart(input); + + // then + expect(result).toThrow(expected); + }); +}); diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..9e271ec6f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,36 @@ +## 📄 기능 목록 + +- 입력 + + - [x] 숫자를 입력받는 기능 + - [x] 게임 재시작 여부를 입력받는 기능 + +- 출력 + + - [x] 게임시작 안내문을 출력하는 기능 + - [x] 숫자 야구 게임 결과를 출력하는 기능 + - [x] 성공 안내문을 출력하는 기능 + +- 기능 + +- [x] 입력한 숫자의 힌트를 제공하는 기능 + - [x] 입력한 숫자의 볼의 개수를 반환하는 기능 + - [x] 입력한 숫자의 스트라이크의 개수를 반환하는 기능 + - [x] 볼, 스트라이크 결과 메시지를 반환하는 기능 +- [x] 1~9까지의 3개의 숫자를 랜덤으로 반환하는 기능 + +## 🎯 예외 상황 + +- 숫자 입력 + + - [x] 3자리가 아닌 경우 + - [x] 숫자가 아닌 경우 + - [x] 음수일 경우 + - [x] 중복일 경우 + - [x] 입력하지 않았을 경우 + +- 재시작 여부 입력 + - [x] 1, 2가 아닌 다른 값을 입력한 경우 + - [x] 음수일 경우 + - [x] 숫자가 아닌 경우 + - [x] 입력하지 않았을 경우 diff --git a/src/App.js b/src/App.js index c38b30d5b..3c9e1d98b 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,11 @@ +import BaseballGameController from './controller/BaseballGameController.js'; + class App { - async play() {} + #baseballGameController = new BaseballGameController(); + + async play() { + await this.#baseballGameController.startGame(); + } } export default App; diff --git a/src/constants/constants.js b/src/constants/constants.js new file mode 100644 index 000000000..23bc78bbd --- /dev/null +++ b/src/constants/constants.js @@ -0,0 +1,22 @@ +const number = Object.freeze({ + zero: 0, + numberSize: 3, +}); + +const range = Object.freeze({ + from: 1, + to: 9, +}); + +const restart = Object.freeze({ + start: 1, + exit: 2, +}); + +const CONSTANTS = Object.freeze({ + number, + range, + restart, +}); + +export default CONSTANTS; diff --git a/src/constants/error.js b/src/constants/error.js new file mode 100644 index 000000000..6b7f50c81 --- /dev/null +++ b/src/constants/error.js @@ -0,0 +1,19 @@ +const numbers = Object.freeze({ + length: '[ERROR] 입력하신 숫자가 3자리가 아닙니다.', + notANumber: '[ERROR] 숫자를 입력해주세요.', + negative: '[ERROR] 숫자가 음수입니다. 다시 입력해주세요.', + duplicated: '[ERROR] 숫자가 중복되었습니다. 다시 입력해주세요.', + empty: '[ERROR] 숫자를 입력해주세요.', +}); + +const restart = Object.freeze({ + choice: '[ERROR] 1, 2가 아닌 다른 값을 입력하셨습니다.', + empty: '[ERROR] 게임 재시작 여부를 입력해주세요.' +}); + +const ERROR = Object.freeze({ + numbers, + restart, +}); + +export default ERROR; diff --git a/src/constants/message.js b/src/constants/message.js new file mode 100644 index 000000000..cf215bb3d --- /dev/null +++ b/src/constants/message.js @@ -0,0 +1,19 @@ +const read = Object.freeze({ + numbers: '숫자를 입력해주세요 : ', + restart: '게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.\n', +}); + +const print = Object.freeze({ + gameStart: '숫자 야구 게임을 시작합니다.', + ball: '볼', + strike: '스트라이크', + nothing: '낫싱', + endGame: '3개의 숫자를 모두 맞히셨습니다! 게임 종료', +}); + +const MESSAGE = Object.freeze({ + read, + print, +}); + +export default MESSAGE; diff --git a/src/controller/BaseBallGameController.js b/src/controller/BaseBallGameController.js new file mode 100644 index 000000000..1e0931928 --- /dev/null +++ b/src/controller/BaseBallGameController.js @@ -0,0 +1,48 @@ +import BaseballGameService from '../service/BaseballGameService.js'; +import InputView from '../view/InputView.js'; +import OutputView from '../view/OutputView.js'; + +class BaseballGameController { + #baseballGameService; + + constructor() { + this.#baseballGameService = new BaseballGameService(); + } + + async startGame() { + OutputView.printStartString(); + + return this.#inputUserNumbers(); + } + + async #inputUserNumbers() { + const numbers = await InputView.readNumbers(); + const { strikeCount, hintMessage } = await this.#baseballGameService.baseballResult(numbers); + + return this.#handleInputOrEnd(strikeCount, hintMessage); + } + + #handleInputOrEnd(strikeCount, hintMessage) { + OutputView.printHintString(hintMessage); + if (this.#baseballGameService.isGameEnd(strikeCount)) { + OutputView.printEndString(); + + return this.#inputRestart(); + } + + return this.#inputUserNumbers(); + } + + async #inputRestart() { + const restart = await InputView.readRestart(); + if (this.#baseballGameService.shouldRestart(restart)) { + this.#baseballGameService.resetGame(); + + return this.#inputUserNumbers(); + } + + return Promise.resolve(); + } +} + +export default BaseballGameController; diff --git a/src/domain/Hint.js b/src/domain/Hint.js new file mode 100644 index 000000000..c87d1a7b9 --- /dev/null +++ b/src/domain/Hint.js @@ -0,0 +1,42 @@ +import CONSTANTS from '../constants/constants.js'; +import MESSAGE from '../constants/message.js'; + +class Hint { + #numbers; + + #computerNumbers; + + constructor(numbers, computerNumbers) { + this.#numbers = numbers; + this.#computerNumbers = computerNumbers; + } + + calculateStrikeCount() { + return this.#numbers.reduce( + (count, digit, index) => (digit === this.#computerNumbers[index] ? count + 1 : count), + 0, + ); + } + + calculateBallCount(strikeCount) { + return ( + this.#numbers.reduce( + (count, digit) => (this.#computerNumbers.includes(digit) ? count + 1 : count), + 0, + ) - strikeCount + ); + } + + generateHintMessage(strikeCount, ballCount) { + const hintMessage = []; + if (ballCount !== CONSTANTS.number.zero) hintMessage.push(`${ballCount}${MESSAGE.print.ball}`); + if (strikeCount !== CONSTANTS.number.zero) + hintMessage.push(`${strikeCount}${MESSAGE.print.strike}`); + if (ballCount === CONSTANTS.number.zero && strikeCount === CONSTANTS.number.zero) + hintMessage.push(MESSAGE.print.nothing); + + return hintMessage; + } +} + +export default Hint; diff --git a/src/service/BaseballGameService.js b/src/service/BaseballGameService.js new file mode 100644 index 000000000..37d62c4e4 --- /dev/null +++ b/src/service/BaseballGameService.js @@ -0,0 +1,34 @@ +import CONSTANTS from '../constants/constants.js'; +import generateRandomNumbers from '../utils/generateRandomNumbers.js'; +import Hint from '../domain/Hint.js'; + +class BaseballGameService { + #computerNumbers; + + constructor() { + this.#computerNumbers = generateRandomNumbers(CONSTANTS.number.numberSize); + } + + async baseballResult(numbers) { + const hint = new Hint(numbers, this.#computerNumbers); + const strikeCount = hint.calculateStrikeCount(); + const ballCount = hint.calculateBallCount(strikeCount); + const hintMessage = hint.generateHintMessage(strikeCount, ballCount); + + return { strikeCount, hintMessage }; + } + + isGameEnd(strikeCount) { + return strikeCount === CONSTANTS.number.numberSize; + } + + shouldRestart(restart) { + return restart === CONSTANTS.restart.start; + } + + resetGame() { + this.#computerNumbers = generateRandomNumbers(CONSTANTS.number.numberSize); + } +} + +export default BaseballGameService; diff --git a/src/utils/generateRandomNumbers.js b/src/utils/generateRandomNumbers.js new file mode 100644 index 000000000..15f1ed798 --- /dev/null +++ b/src/utils/generateRandomNumbers.js @@ -0,0 +1,13 @@ +import { Random } from '@woowacourse/mission-utils'; +import CONSTANTS from '../constants/constants.js'; + +const generateRandomNumbers = length => { + const randomNumbers = []; + while (randomNumbers.length < length) { + const number = Random.pickNumberInRange(CONSTANTS.range.from, CONSTANTS.range.to); + if (!randomNumbers.includes(number)) randomNumbers.push(number); + } + return randomNumbers; +}; + +export default generateRandomNumbers; diff --git a/src/validators/NumbersValidator.js b/src/validators/NumbersValidator.js new file mode 100644 index 000000000..44d24d6ac --- /dev/null +++ b/src/validators/NumbersValidator.js @@ -0,0 +1,37 @@ +import CONSTANTS from '../constants/constants.js'; +import ERROR from '../constants/error.js'; + +class NumbersValidator { + static validateNumbers(numbers) { + const validators = [ + this.#validateEmpty, + this.#validateNegative, + this.#validateNaN, + this.#validateLength, + this.#validateDuplicated, + ]; + validators.forEach(validator => validator(numbers)); + } + + static #validateLength(numbers) { + if (numbers.length !== CONSTANTS.number.numberSize) throw new Error(ERROR.numbers.length); + } + + static #validateNaN(numbers) { + if (Number.isNaN(Number(numbers))) throw new Error(ERROR.numbers.notANumber); + } + + static #validateNegative(numbers) { + if (Number(numbers) < CONSTANTS.number.zero) throw new Error(ERROR.numbers.negative); + } + + static #validateDuplicated(numbers) { + if (numbers.length !== new Set(numbers).size) throw new Error(ERROR.numbers.duplicated); + } + + static #validateEmpty(numbers) { + if (numbers.length === CONSTANTS.number.zero) throw new Error(ERROR.numbers.empty); + } +} + +export default NumbersValidator; diff --git a/src/validators/RestartValidator.js b/src/validators/RestartValidator.js new file mode 100644 index 000000000..869553219 --- /dev/null +++ b/src/validators/RestartValidator.js @@ -0,0 +1,33 @@ +import CONSTANTS from '../constants/constants.js'; +import ERROR from '../constants/error.js'; + +class RestartValidator { + static validateRestart(restart) { + const validators = [ + this.#validateEmpty, + this.#validateNaN, + this.#validateNegative, + this.#validateRestartChoice, + ]; + validators.forEach(validator => validator(restart)); + } + + static #validateRestartChoice(restart) { + if (Number(restart) < CONSTANTS.restart.start || Number(restart) > CONSTANTS.restart.exit) + throw new Error(ERROR.restart.choice); + } + + static #validateNegative(restart) { + if (Number(restart) < CONSTANTS.number.zero) throw new Error(ERROR.numbers.negative); + } + + static #validateNaN(restart) { + if (Number.isNaN(Number(restart))) throw new Error(ERROR.numbers.notANumber); + } + + static #validateEmpty(restart) { + if (restart.length === CONSTANTS.number.zero) throw new Error(ERROR.restart.empty); + } +} + +export default RestartValidator; diff --git a/src/view/InputView.js b/src/view/InputView.js new file mode 100644 index 000000000..bfc6f13aa --- /dev/null +++ b/src/view/InputView.js @@ -0,0 +1,20 @@ +import { Console } from '@woowacourse/mission-utils'; +import MESSAGE from '../constants/message.js'; +import NumbersValidator from '../validators/NumbersValidator.js'; +import RestartValidator from '../validators/RestartValidator.js'; + +const InputView = { + async readNumbers() { + const numbers = await Console.readLineAsync(MESSAGE.read.numbers); + NumbersValidator.validateNumbers(numbers); + return [...numbers].map(Number); + }, + + async readRestart() { + const restart = await Console.readLineAsync(MESSAGE.read.restart); + RestartValidator.validateRestart(restart); + return Number(restart); + }, +}; + +export default InputView; diff --git a/src/view/OutputView.js b/src/view/OutputView.js new file mode 100644 index 000000000..661879686 --- /dev/null +++ b/src/view/OutputView.js @@ -0,0 +1,18 @@ +import { Console } from '@woowacourse/mission-utils'; +import MESSAGE from '../constants/message.js'; + +const OutputView = { + printStartString() { + Console.print(MESSAGE.print.gameStart); + }, + + printHintString(hintMessage) { + Console.print(hintMessage.join(' ')); + }, + + printEndString() { + Console.print(MESSAGE.print.endGame); + }, +}; + +export default OutputView;