diff --git a/README.md b/README.md index e078fd41..5031f9b1 100644 --- a/README.md +++ b/README.md @@ -1 +1,172 @@ # javascript-racingcar-precourse + +## 프리코스 2주차 - 자동차 경주 + +### 과제 진행 요구 사항 + +- 미션은 [자동차 경주](https://github.com/woowacourse-precourse/javascript-racingcar-8) 저장소를 포크하고 클론하는 것으로 시작한다. +- 기능을 구현하기 전 `README.md`에 구현할 기능 목록을 정리해 추가한다. +- Git의 커밋 단위는 앞 단계에서 `README.md`에 정리한 기능 목록 단위로 추가한다. +- [AngularJS Git Commit Message Conventions](https://gist.github.com/stephenparish/9941e89d80e2bc58a153)을 참고해 커밋 메시지를 작성한다. +- 자세한 과제 진행 방법은 프리코스 진행 가이드 문서를 참고한다. + +### 과제 기능 요구 사항 + +- 주어진 횟수 동안 n대의 자동차는 전진 또는 멈출 수 있다. + 각 자동차에 이름을 부여할 수 있다. + 전진하는 자동차를 출력할 때 자동차 이름을 같이 출력한다. + +- 자동차 이름은 쉼표(,)를 기준으로 구분하며 이름은 5자 이하만 가능하다. + +- 사용자는 몇 번의 이동을 할 것인지를 입력할 수 있어야 한다. + +- 전진하는 조건은 0에서 9 사이에서 무작위 값을 구한 후 무작위 값이 4 이상일 경우이다. + +- 자동차 경주 게임을 완료한 후 누가 우승했는지를 알려준다. 우승자는 한 명 이상일 수 있다. + +- 우승자가 여러 명일 경우 쉼표(,)를 이용하여 구분한다. + +- 사용자가 잘못된 값을 입력할 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시킨 후 애플리케이션은 종료되어야 한다. + +--- + +### 입출력 요구 사항 + +#### 입력 + +- 경주할 자동차 이름(이름은 쉼표(,) 기준으로 구분) + +``` +pobi,woni,jun +``` + +- 시도할 횟수 + +``` +5 +``` + +#### 출력 + +- 차수별 실행 결과 + +``` +pobi : -- +woni : ---- +jun : --- +``` + +- 단독 우승자 안내 문구 + +``` +최종 우승자 : pobi +``` + +- 공동 우승자 안내 문구 + +``` +최종 우승자 : pobi, jun +``` + +#### 실행 결과 예시 + +``` +경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분) +pobi,woni,jun +시도할 횟수는 몇 회인가요? +5 + +실행 결과 +pobi : - +woni : +jun : - + +pobi : -- +woni : - +jun : -- + +pobi : --- +woni : -- +jun : --- + +pobi : ---- +woni : --- +jun : ---- + +pobi : ----- +woni : ---- +jun : ----- + +최종 우승자 : pobi, jun +``` + +### 프로그래밍 요구 사항 1 + +- Node.js 22.19.0 버전에서 실행 가능해야 한다. +- 프로그램 실행의 시작점은 App.js의 run()이다. +- package.json 파일은 변경할 수 없으며, 제공된 라이브러리와 스타일 라이브러리 이외의 외부 라이브러리는 사용하지 않는다. +- 프로그램 종료 시 process.exit()를 호출하지 않는다. +- 프로그래밍 요구 사항에서 달리 명시하지 않는 한 파일, 패키지 등의 이름을 바꾸거나 이동하지 않는다. +- 자바스크립트 코드 컨벤션을 지키면서 프로그래밍한다. +- 기본적으로 [JavaScript Style Guide](https://github.com/woowacourse/woowacourse-docs/tree/main/styleguide/javascript)를 원칙으로 한다. + +### 프로그래밍 요구 사항 2 + +- indent(인덴트, 들여쓰기) depth를 3이 넘지 않도록 구현한다. 2까지만 허용한다. + - 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다. + - 힌트: indent(인덴트, 들여쓰기) depth를 줄이는 좋은 방법은 함수(또는 메서드)를 분리하면 된다. +- 3항 연산자를 쓰지 않는다. +- 함수(또는 메서드)가 한 가지 일만 하도록 최대한 작게 만들어라. +- Jest를 이용하여 정리한 기능 목록이 정상적으로 작동하는지 테스트 코드로 확인한다. + - 테스트 도구 사용법이 익숙하지 않다면 아래 문서를 참고하여 학습한 후 테스트를 구현한다. + - [Using Matchers](https://jestjs.io/docs/using-matchers) + - [Testing Asynchronous Code](https://jestjs.io/docs/asynchronous) + - [Jest로 파라미터화 테스트하기: test.each(), describe.each()](https://www.daleseo.com/jest-each/) + +### 자동차 경주 계산기 기능 구현 목록 + +- [x] 자동차 이름 입력 기능 + - [x] 입력받은 이름을 쉼표(`,`) 기준으로 분리하여 배열로 저장 + +- [x] 시도할 횟수 입력 기능 + - [x] 시도할 횟수 입력받은 후 Number 자료형으로 변환하여 저장 + +- [x] 진행 상황을 저장할 수 있는 배열 객체 생성 + - [x] 이름과 점수를 key와 value의 형태로 저장할 수 있도록 생성 + +- [x] 자동차별 레이스 진행 기능 + - [x] 각 자동차는 `0~9` 사이의 무작위 값을 기준으로 전진 또는 멈춤을 결정 + - [x] 난수의 범위: `0~9` + - [x] 점수 증가 조건: `4 이상일 경우` + - [x] 시도할 횟수만큼 레이스 라운드를 반복 + - [x] 각 라운드의 실행 결과를 출력 + +- [x] 레이스 결과를 바탕으로 최종 우승자 결정 후 출력 + - [x] 가장 많이 전진한 자동차를 최종 우승자로 선정 + - [x] 최종 우승자를 형식에 맞게 출력 + - [x] 최종 우승자가 여러 명일 경우 쉼표(,)로 구분하여 출력 + +--- + +입력 공통 : `[ERROR]`로 시작하는 메시지와 함께 `Error`로 시작하는 에러 메시지를 출력하고 애플리케이션을 종료한다. + +- [x] 자동차 입력 예외 처리 + - [x] 자동차 이름이 5자를 초과하는 경우 + - [x] 자동차 이름이 문자열이 아닌 경우 + - [x] 자동차 이름이 공백이거나, 쉼표만 입력된 경우(예: `a,,b` , `a,b,`, `,`) + +- [x] 시도할 횟수 예외 처리 + - [x] 1 이상의 숫자가 아닌 경우 (예: 문자, 공백) + - [x] 0 또는 음수를 입력한 경우 + +--- + +- [x] 테스트 코드 작성 + - [x] GameLogic.js 테스트 + - [x] CircuitScore.js 테스트 + - [x] PrintExeResult.js 테스트 + - [x] 유효성 함수 테스트 + +``` + +``` diff --git a/__tests__/CircuitScore.test.js b/__tests__/CircuitScore.test.js new file mode 100644 index 00000000..fabaa0b8 --- /dev/null +++ b/__tests__/CircuitScore.test.js @@ -0,0 +1,54 @@ +import { MissionUtils } from '@woowacourse/mission-utils'; +import CircuitScore from '../src/CircuitScore.js'; + +// CircuitScore는 Random만 필요 +const mockRandoms = (numbers) => { + MissionUtils.Random.pickNumberInRange = jest.fn(); + numbers.reduce((acc, number) => { + return acc.mockReturnValueOnce(number); + }, MissionUtils.Random.pickNumberInRange); +}; + +describe('CircuitScore.js 테스트', () => { + test('랜덤 숫자가 4 이상일 때 score에 "-"가 추가되어야 한다', () => { + // 4 이상의 값은 모두 전진 + mockRandoms([4, 9]); + + const cars = [ + { name: 'pobi', score: '' }, + { name: 'woni', score: '-' }, + ]; + CircuitScore(cars); + + expect(cars[0].score).toBe('-'); + expect(cars[1].score).toBe('--'); + }); + + test('랜덤 숫자가 3 이하일 때 score가 변하지 않아야 한다', () => { + // 4 이하의 값은 모두 유지 + mockRandoms([0, 3]); + + const cars = [ + { name: 'pobi', score: '' }, + { name: 'woni', score: '-' }, + ]; + CircuitScore(cars); + + expect(cars[0].score).toBe(''); + expect(cars[1].score).toBe('-'); + }); + + test('전진과 정지가 섞여있을 때 올바르게 동작해야 한다', () => { + // pobi (전진), woni (정지) + mockRandoms([5, 1]); + + const cars = [ + { name: 'pobi', score: '-' }, + { name: 'woni', score: '--' }, + ]; + CircuitScore(cars); + + expect(cars[0].score).toBe('--'); + expect(cars[1].score).toBe('--'); + }); +}); diff --git a/__tests__/GameLogic.test.js b/__tests__/GameLogic.test.js new file mode 100644 index 00000000..d037eb38 --- /dev/null +++ b/__tests__/GameLogic.test.js @@ -0,0 +1,123 @@ +import { MissionUtils } from '@woowacourse/mission-utils'; +import * as GameLogic from '../src/GameLogic.js'; +import { ERROR_MESSAGE } from '../src/Constants.js'; + +// ApplicationTest.js의 헬퍼 함수들 +// MissionUtils의 Console, Random을 모의(mock) 객체로 설정 +const mockQuestions = (inputs) => { + MissionUtils.Console.readLineAsync = jest.fn(); + inputs.reduce((acc, input) => { + return acc.mockResolvedValueOnce(input); + }, MissionUtils.Console.readLineAsync); +}; + +const mockRandoms = (numbers) => { + MissionUtils.Random.pickNumberInRange = jest.fn(); + numbers.reduce((acc, number) => { + return acc.mockReturnValueOnce(number); + }, MissionUtils.Random.pickNumberInRange); +}; + +const getLogSpy = () => { + const logSpy = jest.spyOn(MissionUtils.Console, 'print'); + logSpy.mockClear(); + return logSpy; +}; + +describe('GameLogic.js 순수 함수 테스트', () => { + test('createCarsObj: 이름 배열을 객체 배열로 정확히 변환해야 한다', () => { + const names = ['pobi', 'woni']; + const expected = [ + { name: 'pobi', score: '' }, + { name: 'woni', score: '' }, + ]; + expect(GameLogic.createCarsObj(names)).toEqual(expected); + }); + + test('getWinners: 단독 우승자를 정확히 찾아내야 한다', () => { + const cars = [ + { name: 'pobi', score: '---' }, + { name: 'woni', score: '-' }, + { name: 'crong', score: '--' }, + ]; + expect(GameLogic.getWinners(cars)).toEqual(['pobi']); + }); + + test('getWinners: 공동 우승자를 정확히 찾아내야 한다', () => { + const cars = [ + { name: 'pobi', score: '---' }, + { name: 'woni', score: '---' }, + { name: 'crong', score: '-' }, + ]; + expect(GameLogic.getWinners(cars)).toEqual(['pobi', 'woni']); + }); + + test('getWinners: 모든 참여자가 점수가 없어도 빈 배열이 아닌 모든 참여자를 반환해야 한다', () => { + const cars = [ + { name: 'pobi', score: '' }, + { name: 'woni', score: '' }, + ]; + expect(GameLogic.getWinners(cars)).toEqual(['pobi', 'woni']); + }); +}); + +describe('GameLogic.js 비동기 및 사이드 이펙트 테스트', () => { + beforeEach(() => { + // 각 테스트가 독립적으로 실행되도록 mock을 초기화 + jest.restoreAllMocks(); + }); + + test('getCarNames: 유효한 이름을 입력받아 배열로 반환해야 한다', async () => { + mockQuestions(['pobi,woni']); + await expect(GameLogic.getCarNames()).resolves.toEqual(['pobi', 'woni']); + }); + + test('getCarNames: 유효하지 않은 이름(길이 초과) 입력 시 에러를 던져야 한다', async () => { + mockQuestions(['pobi,javaji']); + await expect(GameLogic.getCarNames()).rejects.toThrow( + ERROR_MESSAGE.INVALID_NAME_LENGTH, + ); + }); + + test('getTryNumber: 유효한 숫자를 입력받아 숫자로 반환해야 한다', async () => { + mockQuestions(['5']); + await expect(GameLogic.getTryNumber()).resolves.toBe(5); + }); + + test('getTryNumber: 유효하지 않은 값(문자) 입력 시 에러를 던져야 한다', async () => { + mockQuestions(['abc']); + await expect(GameLogic.getTryNumber()).rejects.toThrow( + ERROR_MESSAGE.INVALID_TRY_NUMBER_RANGE, + ); + }); + + test('getTryNumber: 유효하지 않은 값(음수) 입력 시 에러를 던져야 한다', async () => { + mockQuestions(['-1']); + await expect(GameLogic.getTryNumber()).rejects.toThrow( + ERROR_MESSAGE.INVALID_TRY_NUMBER_RANGE, + ); + }); + + test('printWinners: 최종 우승자를 정확한 포맷으로 출력해야 한다', () => { + const logSpy = getLogSpy(); + const cars = [ + { name: 'pobi', score: '---' }, + { name: 'woni', score: '---' }, + ]; + GameLogic.printWinners(cars); + + expect(logSpy).toHaveBeenCalledWith('최종 우승자 : pobi, woni'); + }); + + test('runRace: 레이스 실행 중 매 라운드 결과를 출력해야 한다', () => { + const logSpy = getLogSpy(); + mockRandoms([4, 3]); + + const cars = GameLogic.createCarsObj(['pobi', 'woni']); + GameLogic.runRace(cars, 1); // 1회 실행 + + // printExeResult가 호출되어 콘솔에 "pobi : -" 와 "woni : "가 찍히는지 확인 + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('pobi : -')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('woni : ')); + }); +}); diff --git a/__tests__/PrintExeResult.test.js b/__tests__/PrintExeResult.test.js new file mode 100644 index 00000000..378ec179 --- /dev/null +++ b/__tests__/PrintExeResult.test.js @@ -0,0 +1,49 @@ +import { MissionUtils } from '@woowacourse/mission-utils'; +import printExeResult from '../src/printExeResult.js'; + +// printExeResult는 Console.print만 필요하므로 getLogSpy만 가져옴 +const getLogSpy = () => { + const logSpy = jest.spyOn(MissionUtils.Console, 'print'); + logSpy.mockClear(); + return logSpy; +}; + +describe('printExeResult.js 테스트', () => { + test('자동차 객체 배열을 받아 올바른 포맷으로 출력해야 한다', () => { + const logSpy = getLogSpy(); + const cars = [ + { name: 'pobi', score: '---' }, + { name: 'woni', score: '-' }, + ]; + + printExeResult(cars); + + // Console.print가 정확히 두 번 호출되었는지 확인 + expect(logSpy).toHaveBeenCalledTimes(2); + + // 첫 번째 호출이 'pobi : ---'인지 확인 + expect(logSpy).toHaveBeenNthCalledWith(1, 'pobi : ---'); + // 두 번째 호출이 'woni : -'인지 확인 + expect(logSpy).toHaveBeenNthCalledWith(2, 'woni : -'); + }); + + test('빈 배열이 들어와도 에러 없이 아무것도 출력하지 않아야 한다', () => { + const logSpy = getLogSpy(); + const cars = []; + + printExeResult(cars); + + // 아무것도 호출되지 않아야 함 + expect(logSpy).not.toHaveBeenCalled(); + }); + + test('점수가 없는 경우 "이름 : " 포맷으로 출력해야 한다', () => { + const logSpy = getLogSpy(); + const cars = [{ name: 'crong', score: '' }]; + + printExeResult(cars); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith('crong : '); + }); +}); diff --git a/__tests__/Validation.test.js b/__tests__/Validation.test.js new file mode 100644 index 00000000..20a54c6d --- /dev/null +++ b/__tests__/Validation.test.js @@ -0,0 +1,41 @@ +import * as Validation from '../src/Validation.js'; +import { ERROR_MESSAGE } from '../src/Constants.js'; + +describe('자동차 이름 입력 값 테스트', () => { + test('이름이 5자를 초과하면 에러 발생', () => { + const inputs = ['pooobi', 'asdf']; + + expect(() => Validation.validateNameLength(inputs)).toThrow( + ERROR_MESSAGE.INVALID_NAME_LENGTH, + ); + }); + + test('이름이 5자 이하면 에러 발생하지 않아야 함', () => { + const normalName = ['pobi', 'woni']; + + expect(() => Validation.validateNameLength(normalName)).not.toThrow(); + }); + + test('입력받은 값이 문자열이 아니면 에러 발생', () => { + const notStringName = [`123`, `123`]; + + expect(() => Validation.validateCarNameType(notStringName)).toThrow( + ERROR_MESSAGE.INVALID_CAR_NAME_TYPE, + ); + }); + + test('입력받은 값이 빈 문자열이면 에러 발생', () => { + const emptyAry = ['']; + expect(() => Validation.validateCarNameExist(emptyAry)).toThrow( + ERROR_MESSAGE.INVALID_CAR_NAME_EMPTY, + ); + }); +}); + +test('횟수 입력이 숫자가 아니면 에러 발생', () => { + const tryNumber = 'af'; + + expect(() => Validation.validateTryNumberType(tryNumber)).toThrow( + ERROR_MESSAGE.INVALID_TRY_NUMBER_RANGE, + ); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5..bae1963c 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,21 @@ +import { Console } from '@woowacourse/mission-utils'; +import * as GameLogic from './GameLogic.js'; + class App { - async run() {} + async run() { + const carAry = await GameLogic.getCarNames(); + const tryNumber = await GameLogic.getTryNumber(); + + // 자동차 이름 배열로 자동차 객체 생성 + const carsObj = GameLogic.createCarsObj(carAry); + + Console.print('\n실행 결과'); + // 레이스 실행 + GameLogic.runRace(carsObj, tryNumber); + + // 최종 우승자 출력 + GameLogic.printWinners(carsObj); + } } export default App; diff --git a/src/CircuitScore.js b/src/CircuitScore.js new file mode 100644 index 00000000..c89f259e --- /dev/null +++ b/src/CircuitScore.js @@ -0,0 +1,11 @@ +import { Random } from '@woowacourse/mission-utils'; + +function CircuitScore(obj) { + const slash = '-'; + obj.forEach((ele) => { + const num = Random.pickNumberInRange(0, 9); + if (num >= 4) ele.score += slash; + }); +} + +export default CircuitScore; diff --git a/src/Constants.js b/src/Constants.js new file mode 100644 index 00000000..a49e139f --- /dev/null +++ b/src/Constants.js @@ -0,0 +1,12 @@ +export const CONSOLE_MESSAGE = { + START_MESSAGE: + '경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)', + SELECT_TRY_NUMBER: '시도할 횟수는 몇 회인가요?', +}; + +export const ERROR_MESSAGE = { + INVALID_NAME_LENGTH: '[ERROR] 이름은 5자 이하만 가능합니다.', + INVALID_CAR_NAME_TYPE: '[ERROR] 자동차 이름이 제대로 입력되지 않았습니다.', + INVALID_CAR_NAME_EMPTY: '[ERROR] 자동차 이름이 공백일 수 없습니다.', + INVALID_TRY_NUMBER_RANGE: '[ERROR] 시도할 횟수는 양수만 입력 가능합니다.', +}; diff --git a/src/GameLogic.js b/src/GameLogic.js new file mode 100644 index 00000000..8f298a0a --- /dev/null +++ b/src/GameLogic.js @@ -0,0 +1,91 @@ +import { Console } from '@woowacourse/mission-utils'; +import { CONSOLE_MESSAGE } from './Constants.js'; +import printExeResult from './printExeResult.js'; +import CircuitScore from './CircuitScore.js'; +import * as Validation from './Validation.js'; + +/** + * 자동차 이름을 입력받고 배열로 반환하는 함수 + * @returns {Promise} + */ +export async function getCarNames() { + const carInput = await Console.readLineAsync(CONSOLE_MESSAGE.START_MESSAGE); + + const carNamesAry = carInput.split(','); + + // 유효성 검사 + Validation.validateNameLength(carNamesAry); + Validation.validateCarNameType(carNamesAry); + Validation.validateCarNameExist(carNamesAry); + + return carNamesAry; +} +/** + * 시도할 입력 횟수를 입력 받고 숫자로 반환하는 함수 + * @returns {Promise} - 시도할 횟수 + */ +export async function getTryNumber() { + const tryNumberInput = await Console.readLineAsync( + CONSOLE_MESSAGE.SELECT_TRY_NUMBER, + ); + + Validation.validateTryNumberType(tryNumberInput); + const tryNumber = Number(tryNumberInput); // 검증 후 숫자로 변환 + Validation.validateTryNumberPositive(tryNumber); + + return tryNumber; +} + +/** + * 주어진 배열을 객체 배열로 생성하는 함수 + * @param {string[]} carNames - 자동차 이름 배열 + * @returns {object[]} + */ +export function createCarsObj(carAry) { + return carAry.map((name) => ({ name, score: '' })); +} + +/** + * 자동차 객체를 매개변수로 받아 레이스 실행 후 결과를 출력하는 함수 + * @param {object[]} cars - 자동차 객체 배열 + * @param {number} tryNumber - 시도 횟수 + */ +export function runRace(cars, tryNumber) { + // 입력받은 횟수만큼 반복하여 각 라운드 결과 출력 + for (let i = 0; i < tryNumber; i += 1) { + CircuitScore(cars); + Console.print(''); + printExeResult(cars); + } +} + +/** + * 레이스가 끝난 자동차 객체에서 최종 우승자를 가려내는 함수 + * @param {object[]} cars - 레이스가 완료된 자동차 객체 배열 + * @returns {string[]} - 최종 우승자 이름 배열 + */ +export function getWinners(cars) { + // score의 길이를 순회하여 새로운 배열로 저장 + const scoreLengthAry = cars.map((a) => a.score.length); + + // 길이가 제일 높은 점수 찾기 + const maxLength = Math.max(...scoreLengthAry); + + // 제일 긴 점수를 바탕으로 해당 점수를 가진 자동차 추출 후 새로운 배열 반환 + const filteredScore = cars.filter((a) => a.score.length === maxLength); + + // filteredScore를 순회하여 최종 우승자만 추출 + const finalWinner = filteredScore.map((winner) => winner.name); + + return finalWinner; +} + +/** + * 최종 우승자를 출력하는 함수 + * @param {object[]} cars - 레이스가 완료된 자동차 객체 배열 + */ +export function printWinners(cars) { + const finalWinner = getWinners(cars); + + Console.print(`최종 우승자 : ${finalWinner.join(', ')}`); +} diff --git a/src/Validation.js b/src/Validation.js new file mode 100644 index 00000000..dc8e7fe4 --- /dev/null +++ b/src/Validation.js @@ -0,0 +1,30 @@ +import { ERROR_MESSAGE } from './Constants.js'; + +export function validateNameLength(ary) { + if (ary.some((x) => x.length > 5) === true) { + throw new Error(ERROR_MESSAGE.INVALID_NAME_LENGTH); + } +} + +export function validateCarNameType(ele) { + if (ele.some((item) => !Number.isNaN(Number(item))) === true) { + throw new Error(ERROR_MESSAGE.INVALID_CAR_NAME_TYPE); + } +} + +export function validateCarNameExist(ele) { + if (ele.includes('')) { + throw new Error(ERROR_MESSAGE.INVALID_CAR_NAME_EMPTY); + } +} + +export function validateTryNumberType(ele) { + if (Number.isNaN(Number(ele)) === true) + throw new Error(ERROR_MESSAGE.INVALID_TRY_NUMBER_RANGE); +} + +export function validateTryNumberPositive(ele) { + if (ele <= 0) { + throw new Error(ERROR_MESSAGE.INVALID_TRY_NUMBER_RANGE); + } +} diff --git a/src/printExeResult.js b/src/printExeResult.js new file mode 100644 index 00000000..b6285891 --- /dev/null +++ b/src/printExeResult.js @@ -0,0 +1,7 @@ +import { Console } from '@woowacourse/mission-utils'; + +export default function printExeResult(ele) { + for (let i = 0; i < ele.length; i += 1) { + Console.print(`${ele[i].name} : ${ele[i].score}`); + } +}