Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
eff3a21
docs(README): 기능 명세서 작성
itwillbeoptimal Oct 22, 2025
94669f3
feat(constants): 에러 메시지 상수 정의
itwillbeoptimal Oct 22, 2025
079c270
feat(constants): 사용자 안내 및 결과 출력 메시지 상수 정의
itwillbeoptimal Oct 22, 2025
1cf4f1c
feat(Car): Car 도메인 클래스 추가
itwillbeoptimal Oct 22, 2025
7910569
feat(RaceCarFactory): 자동차 이름으로 Car 인스턴스를 생성하는 팩토리 클래스 추가
itwillbeoptimal Oct 22, 2025
b8c8d11
feat(RaceStepProcessor): 단일 실행 단계를 담당하는 단계 처리 클래스 추가
itwillbeoptimal Oct 22, 2025
812e193
feat(RaceSimulator): 자동차 경주 시뮬레이션 관리 클래스 추가
itwillbeoptimal Oct 22, 2025
d07a1c0
feat(InputView): 콘솔 입력을 처리하는 View 클래스 추가
itwillbeoptimal Oct 22, 2025
ec96c35
feat(OutputView): 콘솔 출력을 담당하는 View 클래스 추가
itwillbeoptimal Oct 22, 2025
e41fa17
feat(RaceController): 자동차 경주 흐름을 제어하는 컨트롤러 클래스 추가
itwillbeoptimal Oct 22, 2025
e77c073
refactor(models): 불필요한 클래스 제거 및 유틸 함수로 단순화
itwillbeoptimal Oct 22, 2025
eb04f07
fix(constants): 자동차 이름 입력 형식 관련 오류 메시지 수정
itwillbeoptimal Oct 22, 2025
4180e62
feat(validators): 자동차 이름 및 시도 횟수 검증 유틸 추가
itwillbeoptimal Oct 22, 2025
96fbbbc
feat(Car): 생성 시 이름 길이 검증 추가
itwillbeoptimal Oct 22, 2025
10e48e4
feat(RaceController): 입력값 검증 로직 추가
itwillbeoptimal Oct 22, 2025
c74388f
feat(RaceSimulator): 시도 횟수 유효성 검증 추가
itwillbeoptimal Oct 22, 2025
fc8936e
feat(raceUtils): 자동차 이름 중복 검증 추가
itwillbeoptimal Oct 22, 2025
29ad891
feat(App): 애플리케이션 실행 시 RaceController 초기화 및 실행
itwillbeoptimal Oct 22, 2025
0fd082f
style(index): import 경로 작은따옴표로 수정
itwillbeoptimal Oct 22, 2025
f2ae844
docs(README): 프로그램 구조 변경을 반영하여 README.md 업데이트
itwillbeoptimal Oct 22, 2025
d4ab24c
refactor(raceUtils): runOneStep 함수 내부 매직 넘버 상수로 분리
itwillbeoptimal Oct 22, 2025
6c76a26
refactor(validators): 자동차 이름 최대 길이를 상수로 분리
itwillbeoptimal Oct 22, 2025
6e8eb9a
refactor(raceUtils): splitNames를 carNameList로 변수명 수정
itwillbeoptimal Oct 22, 2025
92fe804
feat(constants): 시도 횟수 관련 에러 메시지 추가
itwillbeoptimal Oct 23, 2025
a75190f
feat(validators): 시도 횟수 검증 로직 강화
itwillbeoptimal Oct 23, 2025
3519037
fix(RaceSimulator): 시도 횟수 검증 함수 교체
itwillbeoptimal Oct 23, 2025
c1355ed
fix(RaceController): 시도 횟수 문자열을 Number로 변환 후 전달
itwillbeoptimal Oct 23, 2025
9af4068
refactor(raceUtils): 자동차 이름 공백 제거 및 중복 검증 순서 변경
itwillbeoptimal Oct 23, 2025
a242ccf
refactor(Car, RaceSimulator): #distance를 #position으로 필드명 수정
itwillbeoptimal Oct 23, 2025
0ccd23d
refactor(RaceController): 불필요한 await 제거
itwillbeoptimal Oct 23, 2025
dd4e12a
test(Car): Car 클래스 단위 테스트 작성
itwillbeoptimal Oct 23, 2025
d04b9c2
test(raceUtils): raceUtils 단위 테스트 작성
itwillbeoptimal Oct 24, 2025
a93183e
test(RaceSimulator): RaceSimulator 단위 테스트 작성
itwillbeoptimal Oct 24, 2025
9307360
chore(constants): 테스트용 설정 상수 정의
itwillbeoptimal Oct 24, 2025
4f58fa0
test(models): 테스트 파일 내 매직 넘버를 TEST_CONFIG 상수로 대체
itwillbeoptimal Oct 24, 2025
1ad10fd
test(App): 자동차 경주 전체 흐름을 검증하는 통합 테스트 추가
itwillbeoptimal Oct 24, 2025
ee782ff
test(App): 통합 테스트 구조 리팩터링
itwillbeoptimal Oct 24, 2025
efd9073
refactor(RaceController): 경주 실행 흐름 메서드 분리
itwillbeoptimal Oct 26, 2025
91fb602
test(Car): 메서드명 변경에 따른 describe 수정
itwillbeoptimal Oct 26, 2025
9470f37
refactor(constants): 경주 관련 상수 분리
itwillbeoptimal Oct 26, 2025
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
79 changes: 78 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,78 @@
# javascript-racingcar-precourse
# 🏎️ 자동차 경주 기능 명세

## 1. Domain

### 1) `Car`

- 자동차의 이름과 이동 거리를 보유합니다.
- 전진 명령을 받아 이동 거리를 1 증가시킵니다.
- 자동차 이름과 현재 이동 거리를 반환합니다.

## 2. Model

### 1) `RaceSimulator`

- 자동차 이름과 시도 횟수를 받아 경주 전체를 관리합니다.
- `createCars` 함수를 사용해 자동차 목록을 생성합니다.
- 남은 시도 횟수를 추적하며, 각 턴마다 `runOneStep` 함수를 호출해 이동을 처리합니다.
- 각 단계의 자동차 위치 정보를 반환합니다.
- 최종 우승자를 계산해 반환합니다.

### 2) `raceUtils`

- 경주 관련 로직을 유틸 함수 형식으로 제공합니다.

#### `createCars(carNames)`

- 쉼표로 구분된 자동차 이름 문자열을 입력받아, 각 이름의 공백을 제거합니다.
- 각 이름에 대해 `Car` 인스턴스를 생성해 배열로 반환합니다.

#### `runOneStep(cars)`

- 자동차 배열을 받아 한 번의 이동 시도를 처리합니다.
- 각 자동차마다 0~9 범위의 난수를 생성하고,
난수가 4 이상일 경우 전진시킵니다.

## 3. View

### 1) `InputView`

- 사용자에게 입력 안내 메시지를 표시합니다.
- 자동차 이름들을 입력받습니다.
- 시도 횟수를 입력받습니다.

### 2) `OutputView`

- 실행 결과 헤더를 출력합니다.
- 단계마다 자동차별 현재 위치를 출력합니다.
- 최종 우승자를 출력합니다.

## 4. Controller

### 1) `RaceController`

- `InputView`를 통해 자동차 이름과 시도 횟수를 입력받습니다.
- `RaceSimulator`를 생성하여 경주를 초기화합니다.
- `OutputView`로 결과 헤더를 출력합니다.
- 남은 시도가 있는 동안 반복하여 각 단계를 실행합니다.
- 각 단계의 결과를 `OutputView`로 출력합니다.
- 경주 종료 후 우승자를 `OutputView`로 출력합니다.

## 5. 예외 처리

### 1) 자동차 이름 검증

- 자동차 이름 목록이 쉼표로 구분된 올바른 형식이 아닌 경우 예외를 발생시킵니다.
- 자동차 이름의 길이를 검증합니다.
- 이름이 5자를 초과하는 경우 예외를 발생시킵니다.
- 이름이 비어있는 경우 예외를 발생시킵니다.
- 이름이 중복된 경우 예외를 발생시킵니다.

### 2) 시도 횟수 검증

- 시도 횟수가 숫자가 아닌 경우 예외를 발생시킵니다.
- 시도 횟수가 0 이하인 경우 예외를 발생시킵니다.

### 3) 에러 메시지

- 모든 예외는 `[ERROR]`로 시작하는 메시지를 제공합니다.
128 changes: 101 additions & 27 deletions __tests__/ApplicationTest.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import App from "../src/App.js";
import { MissionUtils } from "@woowacourse/mission-utils";
import { MissionUtils } from '@woowacourse/mission-utils';
import App from '../src/App.js';
import TEST_CONFIG from '../src/constants/testConfig.js';
import ERROR_MESSAGES from '../src/constants/errorMessages.js';

const mockQuestions = (inputs) => {
MissionUtils.Console.readLineAsync = jest.fn();
Expand All @@ -19,42 +21,114 @@ const mockRandoms = (numbers) => {
};

const getLogSpy = () => {
const logSpy = jest.spyOn(MissionUtils.Console, "print");
const logSpy = jest.spyOn(MissionUtils.Console, 'print');
logSpy.mockClear();
return logSpy;
};

describe("자동차 경주", () => {
test("기능 테스트", async () => {
// given
const MOVING_FORWARD = 4;
const STOP = 3;
const inputs = ["pobi,woni", "1"];
const logs = ["pobi : -", "woni : ", "최종 우승자 : pobi"];
const logSpy = getLogSpy();
const runApp = async (inputs, randoms = []) => {
mockQuestions(inputs);
if (randoms.length > 0) {
mockRandoms(randoms);
}
const app = new App();
await app.run();
};

const expectLogsContain = (logSpy, logs) => {
logs.forEach((log) => {
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
});
};

describe('자동차 경주 통합 테스트', () => {
describe('정상 동작 시나리오', () => {
test('우승자가 한 명인 경우 단독 우승자를 출력한다', async () => {
// given
const inputs = ['pobi,woni', '1'];
const logs = ['pobi : -', 'woni : ', '최종 우승자 : pobi'];
const logSpy = getLogSpy();

// when
await runApp(inputs, [TEST_CONFIG.MOVING_FORWARD, TEST_CONFIG.STOP]);

// then
expectLogsContain(logSpy, logs);
});

test('우승자가 여러 명인 경우 모든 우승자를 출력한다', async () => {
// given
const inputs = ['pobi,woni,jun', '2'];
const logs = ['pobi : --', 'woni : --', 'jun : -', '최종 우승자 : pobi, woni'];
const logSpy = getLogSpy();

// when
await runApp(inputs, [
TEST_CONFIG.MOVING_FORWARD,
TEST_CONFIG.MOVING_FORWARD,
TEST_CONFIG.MOVING_FORWARD,
TEST_CONFIG.MOVING_FORWARD,
TEST_CONFIG.MOVING_FORWARD,
TEST_CONFIG.STOP,
]);

// then
expectLogsContain(logSpy, logs);
});

mockQuestions(inputs);
mockRandoms([MOVING_FORWARD, STOP]);
test('자동차 이름 앞뒤의 공백을 제거하고 경주를 진행한다', async () => {
// given
const inputs = [' pobi , woni ', '1'];
const logs = ['pobi : -', 'woni : -'];
const logSpy = getLogSpy();

// when
const app = new App();
await app.run();
// when
await runApp(inputs, [TEST_CONFIG.MOVING_FORWARD, TEST_CONFIG.MOVING_FORWARD]);

// then
logs.forEach((log) => {
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log));
// then
expectLogsContain(logSpy, logs);
});
});

test("예외 테스트", async () => {
// given
const inputs = ["pobi,javaji"];
mockQuestions(inputs);
describe('유효성 검사 예외 케이스', () => {
test('자동차 이름이 5자를 초과하면 예외가 발생한다', async () => {
// given
const inputs = ['pobi,javaji', '1'];

// when
const app = new App();
// when & then
await expect(runApp(inputs)).rejects.toThrow(ERROR_MESSAGES.NAME_TOO_LONG);
});

test('중복된 자동차 이름이 있으면 예외가 발생한다', async () => {
// given
const inputs = ['pobi,woni,pobi', '1'];

// when & then
await expect(runApp(inputs)).rejects.toThrow(ERROR_MESSAGES.NAME_DUPLICATED);
});

test('자동차 이름에 빈 값이 있으면 예외가 발생한다', async () => {
// given
const inputs = ['pobi,,woni', '1'];

// when & then
await expect(runApp(inputs)).rejects.toThrow(ERROR_MESSAGES.NAME_LIST_FORMAT_INVALID);
});

test('시도 횟수가 숫자가 아닌 문자열이면 예외가 발생한다', async () => {
// given
const inputs = ['pobi,woni', 'abc'];

// when & then
await expect(runApp(inputs)).rejects.toThrow(ERROR_MESSAGES.ATTEMPT_COUNT_NON_NUMERIC);
});

test('시도 횟수가 0이하면 예외가 발생한다', async () => {
// given
const inputs = ['pobi,woni', '0'];

// then
await expect(app.run()).rejects.toThrow("[ERROR]");
// when & then
await expect(runApp(inputs)).rejects.toThrow(ERROR_MESSAGES.ATTEMPT_COUNT_NOT_POSITIVE);
});
});
});
7 changes: 6 additions & 1 deletion src/App.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import RaceController from './controllers/RaceController.js';

class App {
async run() {}
async run() {
const controller = new RaceController();
Copy link

Choose a reason for hiding this comment

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

의존성이나 상태 관리, 확장성 관점에선 새로운 단일 인스턴스 생성을 constructor에서 하면 어떨까 생각했는데
어떻게 생각하시는지 궁금합니다!

Copy link
Author

Choose a reason for hiding this comment

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

저는 run()이 한 번 실행되고 종료되는 단일 실행 구조라 RaceController를 재사용하거나 필드로 보관할 필요는 없다고 생각했습니다.

constructor에서 생성하는 방식은 App 인스턴스가 여러 번 재사용되거나, 컨트롤러를 여러 메서드에서 공유해야 하거나, 의존성을 명시적으로 주입받아야 할 때 적합할 것 같습니다.
하지만 현재 구조에서는 App이 단순히 진입점 역할만 수행하고, 컨트롤러를 한 번만 사용하고 종료하기 때문에 지역 변수로 충분하다고 생각했습니다! 오히려 필드로 두면 왜 이게 필드인가?라는 의문이 생길 수 있을 것 같아요.

RaceController 내부에서도 RaceSimulatorrunRace() 메서드 안에서 생성하는 식으로, 필요한 시점에 만드는 흐름을 전체적으로 맞췄습니다~

await controller.start();
}
}
Comment on lines +4 to 8
Copy link

Choose a reason for hiding this comment

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

App에서 의도적으로 try...catch를 빼신 건지 궁금합니다!

물론 굳이 쓰지 않아도 throw error에서 에러를 던지며 프로그램이 종료되긴 하지만

그 종료된 이후의 흐름을 제어하기 위해선 error 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.

1주차 미션에서는 테스트를 통과하기 위해 컨트롤러에서 try-catch로 예외를 잡은 뒤, Console.print로 에러 메시지를 출력하고 다시 예외를 그대로 throw하는 구조로 작성했었습니다!

다만 이 구조가 조금 어색하다고 느껴져서, 2주차 미션에서는 메시지 출력 후 예외를 다시 던지는 부분은 제거했습니다. 말씀해 주신 것처럼 별도의 에러 상황에 대한 예외 처리가 필요하지 않아서 상위 레벨에서 try-catch를 사용하지 않았습니다.


export default App;
14 changes: 14 additions & 0 deletions src/constants/errorMessages.js

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.

따로 참고하고 있는 자료는 없고, 평소에 네이밍에 시간을 오래 쓰는 것 같아요 🥹 기능 구현이 끝난 후에 함수명이나 변수명을 다시 검토하는 것도 좋은 습관인 것 같아요~

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const ERROR_PREFIX = '[ERROR]';
Copy link

Choose a reason for hiding this comment

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

저도 다음부터는 prefix로 만들어서 사용해봐야겠어요!


const ERROR_MESSAGES = Object.freeze({
NAME_TOO_LONG: `${ERROR_PREFIX} 자동차 이름은 5자 이하여야 합니다.`,
NAME_EMPTY: `${ERROR_PREFIX} 자동차 이름을 입력해야 합니다.`,
NAME_LIST_FORMAT_INVALID: `${ERROR_PREFIX} 자동차 이름은 쉼표로 구분된 올바른 형식으로 입력해야 합니다.`,
NAME_DUPLICATED: `${ERROR_PREFIX} 자동차 이름은 중복될 수 없습니다.`,
ATTEMPT_COUNT_EMPTY: `${ERROR_PREFIX} 시도 횟수를 입력해야 합니다.`,
ATTEMPT_COUNT_NON_NUMERIC: `${ERROR_PREFIX} 시도 횟수는 숫자만 입력해야 합니다.`,
ATTEMPT_COUNT_NOT_POSITIVE: `${ERROR_PREFIX} 시도 횟수는 0보다 커야 합니다.`,
ATTEMPT_COUNT_NOT_INTEGER: `${ERROR_PREFIX} 시도 횟수는 정수로 입력해야 합니다.`,
});

export default ERROR_MESSAGES;
8 changes: 8 additions & 0 deletions src/constants/promptMessages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const PROMPT_MESSAGES = Object.freeze({
CAR_NAMES_INPUT: '경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)\n',
ATTEMPT_COUNT_INPUT: '시도할 횟수는 몇 회인가요?\n',
RESULT_HEADER: '\n실행 결과',
FINAL_WINNERS_PREFIX: '최종 우승자',
});

export default PROMPT_MESSAGES;
8 changes: 8 additions & 0 deletions src/constants/raceConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const RACE_CONFIG = Object.freeze({
Copy link

Choose a reason for hiding this comment

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

하드 코딩 대신 상수로 잘 정의하시는 것 같아요!

MOVE_THRESHOLD: 4,
MIN_RANDOM_VALUE: 0,
MAX_RANDOM_VALUE: 9,
MAX_CAR_NAME_LENGTH: 5,
});

export default RACE_CONFIG;
6 changes: 6 additions & 0 deletions src/constants/testConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const TEST_CONFIG = Object.freeze({
MOVING_FORWARD: 4,
STOP: 3,
});

export default TEST_CONFIG;
51 changes: 51 additions & 0 deletions src/controllers/RaceController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import RaceSimulator from '../models/RaceSimulator.js';
import InputView from '../views/InputView.js';
import OutputView from '../views/OutputView.js';
import { validateNameListFormat, validateNumericValue } from '../utils/validators.js';
import PROMPT_MESSAGES from '../constants/promptMessages.js';

class RaceController {
#inputView;
#outputView;

constructor() {
this.#inputView = new InputView();
this.#outputView = new OutputView();
}

Choose a reason for hiding this comment

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

생성자를 통해 바로 값을 입력 받아서 넣는게 처리가 굉장히 깔끔한 것 같습니다. 저는 별도로 입력을 받은 후에 클래스를 선언하여 사용했는데 너무 좋은 방법 배워갑니다!


async start() {
const carNames = await this.#readCarNames();
validateNameListFormat(carNames);
const attemptCount = await this.#readAttemptCount();
validateNumericValue(attemptCount);
this.#runRace(carNames, Number(attemptCount));
}

async #readCarNames() {
return this.#inputView.readString(PROMPT_MESSAGES.CAR_NAMES_INPUT);
}

async #readAttemptCount() {
return this.#inputView.readString(PROMPT_MESSAGES.ATTEMPT_COUNT_INPUT);
}

#printRaceProgress(simulator) {
this.#outputView.printResultHeader();
while (simulator.hasRemainingAttempts()) {
const positions = simulator.executeStep();
this.#outputView.printStepResult(positions);
}
}

#printFinalResults(simulator) {
this.#outputView.printWinners(simulator.getRaceWinners());
}

#runRace(carNames, attemptCount) {
const simulator = new RaceSimulator(carNames, attemptCount);
this.#printRaceProgress(simulator);
this.#printFinalResults(simulator);
}
Comment on lines +44 to +48
Copy link

Choose a reason for hiding this comment

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

start 메서드에서 게임 진행과 출력 로직을 분리하여 runRace 메서드로 나눈게 읽기 편했습니다

Comment on lines +31 to +48
Copy link

Choose a reason for hiding this comment

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

개인적으로는 runRace가 다른 두 메서드보다 먼저 선언되는 게 흐름 상 읽기 좋을 것 같습니당

}

export default RaceController;
26 changes: 26 additions & 0 deletions src/domains/Car.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { validateNameLength } from '../utils/validators.js';

class Car {
#name;
#position;

constructor(name) {
validateNameLength(name);
Copy link

@iftype iftype Oct 27, 2025

Choose a reason for hiding this comment

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

validator를 생성자 안에 넣어 안전한 상태를 유지할 수도 있다는 걸 배워갑니다..

this.#name = name;
this.#position = 0;
}

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

getName() {
return this.#name;
}

getPosition() {
return this.#position;
}
}

export default Car;
Loading