diff --git a/README.md b/README.md index e078fd41..2c2a9ef3 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ # javascript-racingcar-precourse + +## 구현할 기능 목록 + +### > 입출력 스켈레톤 구현 + +- 프로그램의 전체적인 입출력 스켈레톤을 구현한다. + +1. 경주에 참가하는 자동차 이름을 입력받는다. +2. 시도할 횟수를 입력받는다. +3. 실행 결과를 출력한다. +4. 실행 결과에 따른 최종 우승자를 출력한다. + +### > 각 단계 로직 구현 (1) + +- 각 단계별 실제 로직을 구현한다. + 문자열을 입력받아 제한 사항을 적용하여 ',' 기준 문자열 parsing을 진행한다. + +### > 각 단계 로직 구현 (2) + +- 횟수를 입력받고, 변수에 저장한다. + +### > 각 단계 로직 구현 (3) + +- 횟수 만큼 iteration하며, 매 iteration마다 조건에 맞게 자동차를 전진시킨다. + +### > 각 단계 로직 구현 (4) + +- 실행 결과를 보고 단독 우승자 혹은 다수의 우승자를 출력한다. diff --git a/src/App.js b/src/App.js index 091aa0a5..f042a20d 100644 --- a/src/App.js +++ b/src/App.js @@ -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; diff --git a/src/__tests__/App.test.js b/src/__tests__/App.test.js new file mode 100644 index 00000000..f6799ff1 --- /dev/null +++ b/src/__tests__/App.test.js @@ -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]") + ); + }); + }); +});