Skip to content
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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,29 @@
# javascript-racingcar-precourse

## 구현할 기능 목록

### > 입출력 스켈레톤 구현

- 프로그램의 전체적인 입출력 스켈레톤을 구현한다.

1. 경주에 참가하는 자동차 이름을 입력받는다.
2. 시도할 횟수를 입력받는다.
3. 실행 결과를 출력한다.
4. 실행 결과에 따른 최종 우승자를 출력한다.

### > 각 단계 로직 구현 (1)

- 각 단계별 실제 로직을 구현한다.
문자열을 입력받아 제한 사항을 적용하여 ',' 기준 문자열 parsing을 진행한다.

### > 각 단계 로직 구현 (2)

- 횟수를 입력받고, 변수에 저장한다.

### > 각 단계 로직 구현 (3)

- 횟수 만큼 iteration하며, 매 iteration마다 조건에 맞게 자동차를 전진시킨다.

### > 각 단계 로직 구현 (4)

- 실행 결과를 보고 단독 우승자 혹은 다수의 우승자를 출력한다.
142 changes: 141 additions & 1 deletion src/App.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,145 @@
import { Console, MissionUtils } from "@woowacourse/mission-utils";
class App {
async run() {}
static ERROR_TITLE = "[ERROR]";
static restriction_carLength = 5;

/**
* State variables
*/
carNames;
iterationCount;

constructor() {
this.carNames = [];
this.iterationCount = 0;
}

// 사용자가 입력한 law car names string을 restriction을 적용하고 validate 하여 배열로 반환합니다.
validateCarNames(carNames) {
if (!carNames || typeof carNames !== "string") return [];

const splitedResult = carNames.split(",");

splitedResult.forEach((item) => {
const trimmedItem = item.trim();
if (trimmedItem.length < 1) {
throw new Error(
`${App.ERROR_TITLE} 경주할 자동차의 이름이 비어있습니다.`
);
}
if (trimmedItem.length > App.restriction_carLength) {
throw new Error(
`${App.ERROR_TITLE} 경주할 자동차의 이름이 5자가 넘습니다. (${item})`
);
}
});

return splitedResult
.map((name) => name.trim())
.filter((name) => name.length > 0);
}

// 레이싱 게임의 규칙에 따라 렌덤 값이 4 이상인 경우 true를 반환하여 move forward를 허용한다.
isMoveForward() {
const randomValue = MissionUtils.Random.pickNumberInRange(0, 9);
if (randomValue >= 4) {
return true;
}
return false;
}

/**
* Stage 1: 레이싱에 참가할 자동차 명을 입력 받습니다.
*/
async runStageReceiveCarNames() {
const unsafeCarNames = await Console.readLineAsync(
"경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)\n"
);
this.carNames = this.validateCarNames(unsafeCarNames);
// Validate for `safeCarNames`
if (this.carNames.length < 1) {
throw new Error(`${App.ERROR_TITLE} 경주할 자동차가 없습니다.`);
}
}

/**
* Stage 2: 레이싱의 라운드 반복 횟수를 입력 받습니다.
*/
async runStageReceiveIterateNumber() {
const unsafeIterationCount = await Console.readLineAsync(
"시도할 횟수는 몇 회인가요?\n"
);
// validate string `unsafeIterationCount`
const count = Number(unsafeIterationCount);
if (isNaN(count)) {
throw new Error(
`${App.ERROR_TITLE} 입력한 시도할 횟수가 숫자가 아닙니다. (${unsafeIterationCount})`
);
}

if (count < 1 || !Number.isInteger(count)) {
// validate number `unsafeIterationCount`
throw new Error(
`${App.ERROR_TITLE} 시도할 횟수는 1 이상의 정수여야 합니다.`
);
}

this.iterationCount = count;
}

/**
* Stage 3-1: 라운드에 참여한 자동차에 대해 순회하여 전진합니다.
*/
iterateCars(carNames, carMovedArray) {
const newCarMovedArray = [...carMovedArray];
for (let j = 0; j < carNames.length; j++) {
if (this.isMoveForward()) {
newCarMovedArray[j] += "-";
}
Console.print(`${carNames[j]} : ${newCarMovedArray[j]}`);
}
return newCarMovedArray;
}
/**
* Stage 3: 게임을 플레이합니다.
*/
runStageStartRace(iteration, carNames) {
let carMovedArray = Array.from({ length: carNames.length }, () => "");
Console.print("\n실행 결과");
for (let i = 0; i < iteration; i++) {
carMovedArray = this.iterateCars(carNames, carMovedArray);
Console.print("");
}
return carMovedArray;
}

/**
* Stage 4: 우승자를 출력합니다.
*/
runStagePrintWinner(result, carNames) {
const moveLengths = result.map((item) => item.length);
const maxMoveLength = Math.max(...moveLengths);

const winners = carNames.filter(
(carName, i) => result[i].length === maxMoveLength
);

Console.print(`최종 우승자 : ${winners.join(", ")}`);
}

async run() {
try {
await this.runStageReceiveCarNames();
await this.runStageReceiveIterateNumber();

const result = this.runStageStartRace(this.iterationCount, this.carNames);

this.runStagePrintWinner(result, this.carNames);
} catch (error) {
Console.print(error.message);
throw error;
}
}
}

export default App;
126 changes: 126 additions & 0 deletions src/__tests__/App.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import App from "../App.js"; // 경로 수정
import { MissionUtils } from "@woowacourse/mission-utils";

// Mocking MissionUtils
const mockReadLineAsync = jest.spyOn(MissionUtils.Console, "readLineAsync");
const mockPrint = jest.spyOn(MissionUtils.Console, "print");
const mockPickNumberInRange = jest.spyOn(
MissionUtils.Random,
"pickNumberInRange"
);

describe("자동차 경주 게임 테스트", () => {
let app;

beforeEach(() => {
app = new App();
jest.clearAllMocks();
});

describe("경주에 참가하는 자동차 이름들의 유효성 검사", () => {
test("이름이 5자를 초과할 시 예외를 발생시킨다.", () => {
const longName = "pobi,woni,jun,seongeun"; // "seongeun" 6자
expect(() => app.validateCarNames(longName)).toThrow("[ERROR]");
});

test(",(comma)사이 이름이 비어있는 경우 예외를 발생시킨다.", () => {
const emptyName = "pobi,,jun";
expect(() => app.validateCarNames(emptyName)).toThrow("[ERROR]");
});

test("runStageReceiveCarNames - 유효한 이름을 입력하면 this.carNames에 저장된다.", async () => {
mockReadLineAsync.mockResolvedValue("pobi,woni,jun");

await app.runStageReceiveCarNames();

expect(app.carNames).toEqual(["pobi", "woni", "jun"]);
});

test("runStageReceiveCarNames - 유효하지 않은 이름 입력 시 예외를 throw한다.", async () => {
mockReadLineAsync.mockResolvedValue("pobi,seongeun"); // seongeun 6자

await expect(app.runStageReceiveCarNames()).rejects.toThrow("[ERROR]");
});
});

describe("라운드 횟수에 대한 유효성 검사", () => {
test("runStageReceiveIterateNumber - 유효한 횟수를 입력하면 this.iterationCount에 저장된다.", async () => {
mockReadLineAsync.mockResolvedValue("5");

await app.runStageReceiveIterateNumber();

expect(app.iterationCount).toBe(5);
});

test("runStageReceiveIterateNumber - 숫자가 아닌 값을 입력하면 예외를 발생시킨다.", async () => {
mockReadLineAsync.mockResolvedValue("abc");
await expect(app.runStageReceiveIterateNumber()).rejects.toThrow(
"[ERROR]"
);
});

test("runStageReceiveIterateNumber - 1 미만의 숫자를 입력하면 예외를 발생시킨다.", async () => {
mockReadLineAsync.mockResolvedValue("0");
await expect(app.runStageReceiveIterateNumber()).rejects.toThrow(
"[ERROR]"
);
});

test("runStageReceiveIterateNumber - 정수가 아닌 숫자를 입력하면 예외를 발생시킨다.", async () => {
mockReadLineAsync.mockResolvedValue("1.5");
await expect(app.runStageReceiveIterateNumber()).rejects.toThrow(
"[ERROR]"
);
});
});

describe("레이싱 경기 라운드 진행에 대한 테스트", () => {
test("랜덤 값이 4 이상이면 true를 반환한다.", () => {
mockPickNumberInRange.mockReturnValue(4);
expect(app.isMoveForward()).toBe(true);

mockPickNumberInRange.mockReturnValue(9);
expect(app.isMoveForward()).toBe(true);
});

test("랜덤 값이 3 이하이면 false를 반환한다.", () => {
mockPickNumberInRange.mockReturnValue(3);
expect(app.isMoveForward()).toBe(false);

mockPickNumberInRange.mockReturnValue(0);
expect(app.isMoveForward()).toBe(false);
});
});

describe("우승자 판별 (runStagePrintWinner)", () => {
test("단독 우승자를 올바르게 출력한다.", () => {
const carNames = ["pobi", "woni", "jun"];
const result = ["---", "-", "--"];
app.runStagePrintWinner(result, carNames);

expect(mockPrint).toHaveBeenLastCalledWith("최종 우승자 : pobi");
});

test("공동 우승자를 쉼표로 구분하여 올바르게 출력한다.", () => {
const carNames = ["pobi", "woni", "jun"];
const result = ["---", "-", "---"]; // pobi, jun 공동 우승일 경우
app.runStagePrintWinner(result, carNames);

expect(mockPrint).toHaveBeenLastCalledWith("최종 우승자 : pobi, jun");
});
});

// 'app.run()' 전체를 테스트하는 로직은 'ApplicationTest.js'와 중복되므로
// 'run' 내부의 예외 처리 로직만 테스트합니다.
describe("최종 실행 (run)", () => {
test("예외 발생 시 [ERROR] 메시지를 출력하고 다시 throw한다.", async () => {
mockReadLineAsync.mockResolvedValue("pobi,seongeun"); // 6자 이름

await expect(app.run()).rejects.toThrow("[ERROR]");

expect(mockPrint).toHaveBeenCalledWith(
expect.stringContaining("[ERROR]")
);
});
});
});