Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[자동차 경주] 임현우 미션 제출합니다. #434

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,87 @@
# javascript-racingcar-precourse

## 구현할 기능 목록

### 1. 기능 구현 (App.js에서 모든 기능 구현)

1. **입력 기능**
- 사용자의 입력을 통해 경주에 참가할 자동차 이름과 시도할 횟수를 입력받습니다.
- `경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)`
- 예시 입력: `pobi,woni , jun`
- 자동차 이름은 5자 이하로 제한되며, 앞뒤 공백이 제거됩니다.
- 길이가 0인 이름은 입력될 수 없습니다.
- 자동차를 **2개 이상** 입력해야 합니다.
- **중복되는 자동차 이름**은 입력할 수 없습니다.
- `시도할 횟수는 몇 회인가요?`
- 예시 입력: `5`
- 시도 횟수는 **1 이상의 양수**여야 합니다.
- 잘못된 입력 시 `[ERROR]`로 시작하는 오류 메시지를 출력하고 프로그램을 종료합니다.

2. **자동차 전진 기능**
- 각 자동차는 **주어진 시도 횟수만큼 전진**하거나 **멈추는** 과정을 반복합니다.
- **전진 조건**: 0에서 9 사이의 랜덤 숫자를 생성하여, 숫자가 **4 이상일 경우** 자동차가 전진합니다.
- 전진 여부는 `Random.pickNumberInRange(0,9)`를 이용하여 결정합니다.

3. **경주 진행 및 결과 출력 기능**
- 각 시도마다 **모든 자동차의 위치**를 출력합니다.
- 출력 예시:
```
실행 결과
pobi : -
woni : -
jun :
```
- 각 **라운드마다 경주 결과**를 모두 출력합니다.

4. **우승자 출력 기능**
- 모든 시도가 완료된 후 가장 멀리 전진한 자동차를 **우승자**로 선정합니다.
- **우승자가 여러 명**일 경우 쉼표`(,)`로 구분하여 출력합니다.
- 예시 출력: `최종 우승자 : pobi, jun`


### 2. 리팩토링 계획 (기능 구현 후)

1. **모듈화 작업**
- 입력 처리, 게임 로직, 유효성 검사를 각각의 **독립적인 모듈**로 분리합니다.
- 상수값 (메시지, 에러 메시지 등)을 **constants.js** 파일로 분리하여 관리합니다.
- 주요 기능들을 각각 별도의 함수나 클래스로 분리하여 가독성을 높입니다.

2. **네이밍 규칙 적용**
- 변수, 함수, 클래스 이름을 **Airbnb JavaScript 스타일 가이드**에 맞춰 정리합니다.
- 예를 들어, **상수**는 대문자와 밑줄(`_`)로 구분하고, 변수와 함수는 **카멜 케이스**를 사용합니다.

3. **깊이(depth) 규칙 준수**
- 들여쓰기 깊이를 최대 **2까지만 허용**하도록 코드를 정리합니다.
- 중첩된 로직이 있을 경우, 이를 **함수로 분리**하여 깊이를 줄이는 방식으로 개선합니다.

4. **코드 리뷰 및 클린 코드 적용**
- 작성한 코드를 다시 검토하여 **중복 코드 제거**, **불필요한 주석 삭제** 등 **클린 코드** 원칙을 적용합니다.
### 3. 구현 및 리팩토링 과정
- **1단계**: App.js에서 **모든 기능을 구현**하고 기능이 정상적으로 동작하는지 확인합니다.
- **2단계**: 기능 구현이 완료되면, 코드를 **리팩토링**하여 가독성과 유지보수성을 높입니다.
- **3단계**: Jest를 이용하여 **테스트 케이스 작성**을 진행합니다.
- **입력 값 검증 테스트**: 자동차 이름이 올바른지, 시도 횟수가 유효한지를 검증합니다.
- **파라미터화 테스트**: 다양한 입력 조합에 대해 반복 테스트하기 위해 `test.each()` 또는 `describe.each()`를 사용합니다.

## 실행 예시
```
경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)
pobi,woni,jun
시도할 횟수는 몇 회인가요?
3

실행 결과
pobi : -
woni :
jun : -

pobi : --
woni : -
jun : --

pobi : --
woni : -
jun : --

최종 우승자 : pobi, jun
```
109 changes: 109 additions & 0 deletions __tests__/myCustomTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import App from '../src/App.js';
import { MissionUtils } from '@woowacourse/mission-utils';
import {
VARIOUS_NAME_TEST_CASES,
ROUND_TEST_CASE,
TWO_WINNERS_TEST_CASE,
EXCEPTION_TEST_CASES,
} from '../data/testCasesData.js';

const mockQuestions = (inputs) => {
MissionUtils.Console.readLineAsync = jest.fn();
MissionUtils.Console.readLineAsync.mockImplementation(() => {
const input = inputs.shift();
return Promise.resolve(input);
});
};

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('자동차 경주', () => {
describe('기능 테스트', () => {
test.each(VARIOUS_NAME_TEST_CASES)(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인적인 의견이고 저도 아직 정답이 뭔지 모르겠지만,, 테스트 코드 안에서 쓰이는 테스트 케이스들을 변수화해서 테스트코드 밖에서 관리하게 된다면 실제 쓰이는 데이터들을 확인하기 위해서 읽던 파일을 이동해야 하니 가독성에 좋지 않다는 생각이 들기도 합니다...! 재사용되는 것이 아니라면 test.each에 바로 작성해주는 것도 좋을 것 같아요!!

'$description',
async ({ inputs, logs }) => {
const MOVING_FORWARD = 4;
const STOP = 3;
const logSpy = getLogSpy();

mockQuestions(inputs);
mockRandoms([MOVING_FORWARD, STOP]);

const app = new App();
await app.run();

for (const log of logs) {
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
}
}
);
});

describe('기능 테스트 - 각 라운드 출력', () => {
test('각 라운드 출력 확인', async () => {
const { inputs, logs } = ROUND_TEST_CASE;
const MOVING_FORWARD = 4;
const STOP = 3;
const logSpy = getLogSpy();

mockQuestions(inputs);
mockRandoms([
MOVING_FORWARD,
STOP,
MOVING_FORWARD,
STOP,
MOVING_FORWARD,
STOP,
]);

const app = new App();
await app.run();

for (const log of logs) {
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
}
});
});

describe('기능 테스트 - 우승자 2인', () => {
test('우승자 2인', async () => {
const { inputs, logs } = TWO_WINNERS_TEST_CASE;
const MOVING_FORWARD = 4;
const logSpy = getLogSpy();

mockQuestions(inputs);
mockRandoms([MOVING_FORWARD, MOVING_FORWARD]);

const app = new App();
await app.run();

for (const log of logs) {
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
}
});
});

describe('자동차 경주 예외 테스트', () => {
test.each(EXCEPTION_TEST_CASES)(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런식으로 한번에 예외처리도 가능했군요!!
예외가 더 추가될 때에도 testCasesData.js파일에만 추가하면 되니 관리가 훨씬 편해질 것 같습니다!!

'입력 값 검증 - 예외 테스트',
async ({ inputs, expectedError }) => {
mockQuestions(inputs);

const app = new App();

await expect(app.run()).rejects.toThrow(expectedError);
}
);
});
});
100 changes: 100 additions & 0 deletions data/testCasesData.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { ERROR_MESSAGE } from '../src/constants/errorMessages.js';

const {
INVALID_CAR_NAMES_LENGTH,
INVALID_CAR_NAME_LENGTH,
DUPLICATE_CAR_NAMES,
INVALID_TRY_COUNT,
} = ERROR_MESSAGE;

const VARIOUS_NAME_TEST_CASES = [
{
description: '한글, 숫자, 특수문자 통과 테스트',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

한글 및 영어 여부를 테스트 케이스로 분리하신 이유가 있으실까요? 한글이든 영어든 둘다 결국 string 값이고 5자의 글자 제한이 들어간건 동일해서 굳이 분리하신 이유가 따로 있을지 궁금합니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트 케이스여서 넣었었습니다. 사실 저는 이런 테스트 케이스 작성은 처음이라 이런 걸 넣어도 작동을 한다~ 보여주는 의미인가 싶어서 넣었죠....

inputs: ['한글1,한글2', '1'],
logs: ['한글1 : -', '한글2 : ', '최종 우승자 : 한글1'],
},
{
description: '숫자만 포함된 자동차 이름 테스트',
inputs: ['1234,5678', '1'],
logs: ['1234 : -', '5678 : ', '최종 우승자 : 1234'],
},
{
description: '한글과 숫자가 혼합된 자동차 이름 테스트',
inputs: ['한글123,한글456', '1'],
logs: ['한글123 : -', '한글456 : ', '최종 우승자 : 한글123'],
},
{
description: '특수문자만 포함된 자동차 이름 테스트',
inputs: ['!@#$,%^&*', '1'],
logs: ['!@#$ : -', '%^&* : ', '최종 우승자 : !@#$'],
},
{
description: '한글과 특수문자가 혼합된 자동차 이름 테스트',
inputs: ['한글!@#,한글%^&', '1'],
logs: ['한글!@# : -', '한글%^& : ', '최종 우승자 : 한글!@#'],
},
{
description: '한글과 영문자가 혼합된 자동차 이름 테스트',
inputs: ['한글abc,한글xyz', '1'],
logs: ['한글abc : -', '한글xyz : ', '최종 우승자 : 한글abc'],
},
];

const ROUND_TEST_CASE = {
inputs: ['pobi,woni', '3'],
logs: [
'pobi : -',
'woni : ',
'', // 첫 라운드 후 빈 줄 출력
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드로 충분히 이해할 수 있어서 불필요한 주석은 안적으시는 것을 추천드립니다! (공통 피드백 내용이었어요 :))

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사합니다. 저는 저렇게 됐을 때 입력 값이 pobi, woni, '' 로 착각할 수 있지 않을까? 라는 우려가 있었습니다.
그런데 말씀대로 input에서 이미 pobi, woni로 적어 놨으니 불필요했겠군요.

'pobi : --',
'woni : ',
'', // 두 번째 라운드 후 빈 줄 출력
'pobi : ---',
'woni : ',
'', // 세 번째 라운드 후 빈 줄 출력
'최종 우승자 : pobi',
],
};

const TWO_WINNERS_TEST_CASE = {
inputs: ['pobi,woni', '1'],
logs: ['pobi : -', 'woni : -', '최종 우승자 : pobi, woni'],
};

const EXCEPTION_TEST_CASES = [
{
inputs: ['pobi'],
expectedError: INVALID_CAR_NAMES_LENGTH,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인적인 취향이 될 수도 있지만, 자칫 INVALID_CAR_NAMES_LENGTHINVALID_CAR_NAME_LENGTH를 사용하다가 헷갈릴 수 있을 것 같다는 생각이 들어요.

INVALID_CAR_LIST_LENGTH과 같은 방식으로 사용하는건 어떤가 싶습니다!

},
{
inputs: ['pobi,moon,'],
expectedError: INVALID_CAR_NAME_LENGTH,
},
{
inputs: ['pobicar,moon'],
expectedError: INVALID_CAR_NAME_LENGTH,
},
{
inputs: ['pobi,pobi'],
expectedError: DUPLICATE_CAR_NAMES,
},
Comment on lines +78 to +80

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

빈 입력값이 들어왔을때에 대한 예외처리가 빠져있는 것 같아요!
추가로 문자열에 공백이 존재할 경우에도 입력을 허용할 것인지, 아니면 공백이 불가능하게 에러를 출력할 것인지에 대한 고민을 해봐도 좋을 것 같아요.

참고로 저는 공백인 경우엔 hi hi 는 구분이 힘들 것 같아 공백을 허용하지 않았습니다! 본인만의 기준이 있다면 공백을 허용하는것도 괜찮을 것 같아요!!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금보니 공백인 경우 removeWhitespace를 통해 공백제거는 해주고 계셨네요!!
좋습니다 :)

{
inputs: ['pobi,woni', '0'],
expectedError: INVALID_TRY_COUNT,
},
{
inputs: ['pobi, woni', '-1'],
expectedError: INVALID_TRY_COUNT,
},
{
inputs: ['pobi,woni ', 'a'],
expectedError: INVALID_TRY_COUNT,
},
];
Comment on lines +90 to +93

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 마찬가지로 입력값이 공백과 빈 값이 들어왔을때에 대한 예외처리가 빠져있는 것 같습니다.
조금 더 세분화하자면 저는 소수인 경우도 예외처리를 해두었는데 이부분은 개인의 취향에 맞게 작성하면 될 것 같아요. :)


export {
VARIOUS_NAME_TEST_CASES,
ROUND_TEST_CASE,
TWO_WINNERS_TEST_CASE,
EXCEPTION_TEST_CASES,
};
47 changes: 46 additions & 1 deletion src/App.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,50 @@
import { userInputCarNames, userInputTryCount } from './utils/getInputUtils.js';
import { splitAndRemoveWhitespace } from './utils/carNameUtils.js';
import {
printRoundStatus,
printToStartGame,
printWinners,
} from './utils/printUtils.js';
import { validateCarNames, validateTryCount } from './validate/validateion.js';
import { createCarObjectsFromNames } from './createCarObjects.js';
import { moveCars } from './gameLogic.js';
import {
getMaxPosition,
filterCarsByMaxPosition,
extractCarNames,
} from './utils/raceResultUtils.js';

class App {
async run() {}
async run() {
try {
const inputCarNames = await userInputCarNames();
const processedCarNames = splitAndRemoveWhitespace(inputCarNames);

validateCarNames(processedCarNames);

const cars = createCarObjectsFromNames(processedCarNames);

const inputTryCount = await userInputTryCount();

const tryCount = Number(inputTryCount);
validateTryCount(tryCount);

printToStartGame();
for (let i = 0; i < tryCount; i++) {
moveCars(cars);
printRoundStatus(cars);
}

const maxPosition = getMaxPosition(cars);

const result = filterCarsByMaxPosition(cars, maxPosition);

const winners = extractCarNames(result);
printWinners(winners);
} catch (error) {
throw error;
}
Comment on lines +44 to +46

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error 를 catch해서 다시 throw 해 주는 특별한 이유가 있나요?? 여기서 catch 해주지 않아도 동일한 결과가 나올 것 같아서요!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사합니다. 처음에 배울 때 이렇게 해라~ 를 듣고 관습적으로 항상 해왔던 것 같아요..!

}
}

export default App;
12 changes: 12 additions & 0 deletions src/Car.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class Car {
constructor(name) {
this.name = name;
this.position = 0;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

외부에서 클래스 내의 변수에 접근할 수 없도록 private 변수로 활용해보시는 것은 어떨까요? ( 이 부분은 저도 받았던 피드백이라 공유드리고 싶어서 말씀드려요 !)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사합니다. 이번주 미션은 그렇게 해봐야겠어요!

}

move() {
this.position += 1;
}
}

export { Car };
9 changes: 9 additions & 0 deletions src/constants/errorMessages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const ERROR_MESSAGE = {
INVALID_CAR_NAMES_LENGTH: '[ERROR] 자동차는 2개 이상 입력해야 합니다.',
INVALID_CAR_NAME_LENGTH:
'[ERROR] 자동차 이름은 1글자 이상 5글자 이하로 입력해야 합니다.',
DUPLICATE_CAR_NAMES: '[ERROR] 중복되는 자동차 이름은 입력할 수 없습니다.',
INVALID_TRY_COUNT: '[ERROR] 시도할 횟수는 양의 정수여야 합니다.',
};

export { ERROR_MESSAGE };
7 changes: 7 additions & 0 deletions src/createCarObjects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Car } from './Car.js';

const createCarObjectsFromNames = (carNames) => {
return carNames.map((name) => new Car(name));
};

export { createCarObjectsFromNames };
16 changes: 16 additions & 0 deletions src/gameLogic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Random } from '@woowacourse/mission-utils';

const moveOnFourOrOver = () => {
const randomNumber = Random.pickNumberInRange(0, 9);
return randomNumber >= 4;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

조건을 리턴하는 함수로 만들 수도 있군요! 배워갑니다 👍

};

const moveCars = (cars) => {
for (const car of cars) {
if (moveOnFourOrOver()) {
car.move();
}
}
};

export { moveCars };
Loading