From 2f60f731f5e745fd464784548b62a54f688499f5 Mon Sep 17 00:00:00 2001 From: sungeunp Date: Sat, 25 Oct 2025 13:48:43 +0900 Subject: [PATCH 01/15] =?UTF-8?q?docs(readme):=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=ED=95=A0=20=EA=B8=B0=EB=8A=A5=20=EB=AA=A9=EB=A1=9D=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 커밋 단위의 구현할 기능 목록을 나열 --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index e078fd41..01e2e8fb 100644 --- a/README.md +++ b/README.md @@ -1 +1,33 @@ # javascript-racingcar-precourse + +## 구현할 기능 목록 + +### > 입출력 스켈레톤 구현 + +- 프로그램의 전체적인 입출력 스켈레톤을 구현한다. + +1. 경주에 참가하는 자동차 이름을 입력받는다. +2. 시도할 횟수를 입력받는다. +3. 실행 결과를 출력한다. +4. 실행 결과에 따른 최종 우승자를 출력한다. + +### > 각 단계 로직 구현 (1) + +- 각 단계별 실제 로직을 구현한다. + 문자열을 입력받아 ',' 기준 문자열 parsing을 진행한다. + +### > 각 단계 로직 구현 (2) + +- 횟수를 입력받고, 변수에 저장한다. + +### > 각 단계 로직 구현 (3) + +- 횟수 만큼 iteration하며, 매 iteration마다 조건에 맞게 자동차를 전진시킨다. + +### > 각 단계 로직 구현 (4) + +- 실행 결과를 보고 단독 우승자 혹은 다수의 우승자를 출력한다. + +### > 사용자 탐구 및 편의성 개선 + +- `WIP` From a9a8fb0a33b93cb0244af3ed697ed82f187d134e Mon Sep 17 00:00:00 2001 From: sungeunp Date: Sat, 25 Oct 2025 13:51:38 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat(skeleton):=20=EC=9E=85=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로그램의 전체적인 입출력 스켈레톤을 구현한다. 1. 경주에 참가하는 자동차 이름을 입력받는다. 2. 시도할 횟수를 입력받는다. 3. 실행 결과를 출력한다. 4. 실행 결과에 따른 최종 우승자를 출력한다. --- src/App.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/App.js b/src/App.js index 091aa0a5..b353c096 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,18 @@ +import { Console } from "@woowacourse/mission-utils"; class App { - async run() {} + async run() { + const unsafe_carNames = await Console.readLineAsync( + "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)\n" + ); + + const unsafe_iterationCount = await Console.readLineAsync( + "시도할 횟수는 몇 회인가요?\n" + ); + + Console.print("\n실행 결과"); + + Console.print(`최종 우승자 : pobi, jun`); + } } export default App; From 862b0497d1d168c9015084d0312632f2e91990b6 Mon Sep 17 00:00:00 2001 From: sungeunp Date: Sat, 25 Oct 2025 14:26:15 +0900 Subject: [PATCH 03/15] =?UTF-8?q?=08docs(readme):=20=EA=B0=84=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20(1)=20?= =?UTF-8?q?=EC=9B=8C=EB=94=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 01e2e8fb..aad1c8d8 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ### > 각 단계 로직 구현 (1) - 각 단계별 실제 로직을 구현한다. - 문자열을 입력받아 ',' 기준 문자열 parsing을 진행한다. + 문자열을 입력받아 제한 사항을 적용하여 ',' 기준 문자열 parsing을 진행한다. ### > 각 단계 로직 구현 (2) From 9dac109ec7d1464c49fc37949dbfa005d41391ee Mon Sep 17 00:00:00 2001 From: sungeunp Date: Sat, 25 Oct 2025 14:27:36 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat(parse):=20=EA=B0=81=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20(1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 각 단계별 실제 로직을 구현한다. 문자열을 입력받아 제한 사항을 적용하여 ',' 기준 문자열 parsing을 진행한다. - 제한 사항을 위반한 경우 exception을 발생시켜, process를 통한 program exit이 아닌 예외를 통한 gracefully-exit을 적용했다. --- src/App.js | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/App.js b/src/App.js index b353c096..32ecc1e2 100644 --- a/src/App.js +++ b/src/App.js @@ -1,9 +1,37 @@ import { Console } from "@woowacourse/mission-utils"; class App { + static ERROR_TITLE = "[ERROR]"; + + // 사용자가 입력한 law car names string을 restriction을 적용하여 배열로 validate 합니다. + static restriction_carLength = 5; + validateCarNames(carNames) { + if (!carNames || typeof carNames !== "string") return []; + + const splitedResult = carNames.split(","); + return splitedResult.filter((item) => { + if (item.length < 1) { + throw new Error( + `${App.ERROR_TITLE} 경주할 자동차의 이름이 비어있습니다.` + ); + } + if (item.length > App.restriction_carLength) { + throw new Error( + `${App.ERROR_TITLE} 경주할 자동차의 이름이 5자가 넘습니다. (${item})` + ); + } + return item.trim() !== ""; + }); + } + async run() { - const unsafe_carNames = await Console.readLineAsync( + const unsafeCarNames = await Console.readLineAsync( "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)\n" ); + const safeCarNames = this.validateCarNames(unsafeCarNames); + // Validate for `safeCarNames` + if (safeCarNames.length < 0) { + throw new Error(`${App.ERROR_TITLE} 경주할 자동차가 없습니다.`); + } const unsafe_iterationCount = await Console.readLineAsync( "시도할 횟수는 몇 회인가요?\n" From 928c4cfb7a6e5439ac1e7f6362c874e1e0ee4114 Mon Sep 17 00:00:00 2001 From: sungeunp Date: Sat, 25 Oct 2025 14:31:15 +0900 Subject: [PATCH 05/15] =?UTF-8?q?docs(comment):=20validate=20function?= =?UTF-8?q?=EC=9D=98=20=EC=A3=BC=EC=84=9D=20=EC=9B=8C=EB=94=A9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.js b/src/App.js index 32ecc1e2..11f34e4b 100644 --- a/src/App.js +++ b/src/App.js @@ -2,7 +2,7 @@ import { Console } from "@woowacourse/mission-utils"; class App { static ERROR_TITLE = "[ERROR]"; - // 사용자가 입력한 law car names string을 restriction을 적용하여 배열로 validate 합니다. + // 사용자가 입력한 law car names string을 restriction을 적용하고 validate 하여 배열로 반환합니다. static restriction_carLength = 5; validateCarNames(carNames) { if (!carNames || typeof carNames !== "string") return []; From 5e2f2b56fec3aa09c139c1c2338f491a1e86cab1 Mon Sep 17 00:00:00 2001 From: sungeunp Date: Sat, 25 Oct 2025 14:40:59 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat(iteration-count):=20=EA=B0=81=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 횟수를 입력받고, 변수에 저장한다. - 숫자가 아닌 경우를 대비하여 try catch exception 구조를 적용했다. - 입력된 횟수가 양수 정수에 해당하지 않는 경우, exception을 통해 프로그램을 종료한다. --- src/App.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/App.js b/src/App.js index 11f34e4b..c1d3b36d 100644 --- a/src/App.js +++ b/src/App.js @@ -33,9 +33,23 @@ class App { throw new Error(`${App.ERROR_TITLE} 경주할 자동차가 없습니다.`); } - const unsafe_iterationCount = await Console.readLineAsync( + const unsafeIterationCount = await Console.readLineAsync( "시도할 횟수는 몇 회인가요?\n" ); + let safeIterationCount = -1; + // validate string `unsafe_iterationCount` + try { + safeIterationCount = Number.parseInt(unsafeIterationCount); + } catch (e) { + throw new Error( + `${App.ERROR_TITLE} 입력한 시도할 횟수가 숫자가 아닙니다. (${unsafeIterationCount})` + ); + } + // validate number `unsafe_iterationCount` + if (safeIterationCount < 1) { + throw new Error("시도할 횟수는 0이나 음수가 될 수 없습니다."); + } + Console.print(`시도할 횟수(test): ${safeIterationCount}`); Console.print("\n실행 결과"); From c7f2b894fe290545b1fe688b4b431101debbef76 Mon Sep 17 00:00:00 2001 From: sungeunp Date: Sat, 25 Oct 2025 14:41:22 +0900 Subject: [PATCH 07/15] =?UTF-8?q?chore(remove-code):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/App.js b/src/App.js index c1d3b36d..5240c432 100644 --- a/src/App.js +++ b/src/App.js @@ -49,7 +49,6 @@ class App { if (safeIterationCount < 1) { throw new Error("시도할 횟수는 0이나 음수가 될 수 없습니다."); } - Console.print(`시도할 횟수(test): ${safeIterationCount}`); Console.print("\n실행 결과"); From 2da4fc78484611b125309ca6945097b494c3133a Mon Sep 17 00:00:00 2001 From: sungeunp Date: Sun, 26 Oct 2025 03:40:16 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat,=20refactor(race):=20=EA=B0=81=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 횟수 만큼 iteration하며, 매 iteration마다 조건에 맞게 자동차를 전진시킨다. - 요구 사항에 맞추어, depth 2를 초과하지 않도록 설계하였다. - 이를 위해 round를 iterate하는 부분과, round에 해당하는 car들을 iterate하는 부분을 모듈화 하였다. - 객체지향 원칙에 따라 전체 로직의 각 stage를 class의 method로 분리하였고, 최대한 객체지향 원칙에 따르도록 리펙토링 했다. --- src/App.js | 74 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/src/App.js b/src/App.js index 5240c432..4b242450 100644 --- a/src/App.js +++ b/src/App.js @@ -1,7 +1,13 @@ -import { Console } from "@woowacourse/mission-utils"; +import { Console, MissionUtils } from "@woowacourse/mission-utils"; class App { static ERROR_TITLE = "[ERROR]"; + /** + * State variables + */ + carNames; + iterationCount; + // 사용자가 입력한 law car names string을 restriction을 적용하고 validate 하여 배열로 반환합니다. static restriction_carLength = 5; validateCarNames(carNames) { @@ -23,34 +29,86 @@ class App { }); } - async run() { + // 레이싱 게임의 규칙에 따라 렌덤 값이 4 이상인 경우 true를 반환하여 move forward를 허용한다. + getMoveForward() { + const randomValue = MissionUtils.Random.pickNumberInRange(0, 9); + if (randomValue >= 4) { + return true; + } + return false; + } + + /** + * Stage 1: 레이싱에 참가할 자동차 명을 입력 받습니다. + */ + async runStageReceiveCarNames() { const unsafeCarNames = await Console.readLineAsync( "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)\n" ); - const safeCarNames = this.validateCarNames(unsafeCarNames); + this.carNames = this.validateCarNames(unsafeCarNames); // Validate for `safeCarNames` - if (safeCarNames.length < 0) { + if (this.carNames.length < 1) { throw new Error(`${App.ERROR_TITLE} 경주할 자동차가 없습니다.`); } + } + /** + * Stage 2: 레이싱의 라운드 반복 횟수를 입력 받습니다. + */ + async runStageReceiveIterateNumber() { const unsafeIterationCount = await Console.readLineAsync( "시도할 횟수는 몇 회인가요?\n" ); - let safeIterationCount = -1; // validate string `unsafe_iterationCount` try { - safeIterationCount = Number.parseInt(unsafeIterationCount); + this.iterationCount = Number.parseInt(unsafeIterationCount); } catch (e) { throw new Error( `${App.ERROR_TITLE} 입력한 시도할 횟수가 숫자가 아닙니다. (${unsafeIterationCount})` ); } // validate number `unsafe_iterationCount` - if (safeIterationCount < 1) { - throw new Error("시도할 횟수는 0이나 음수가 될 수 없습니다."); + if (this.iterationCount < 1) { + throw new Error( + `${App.ERROR_TITLE} 시도할 횟수는 0이나 음수가 될 수 없습니다.` + ); } + } + /** + * Stage 3-1: 라운드에 참여한 자동차에 대해 순회하여 전진합니다. + */ + iterateCars(carNames, carMovedArray) { + for (let j = 0; j < carNames.length; j++) { + if (this.getMoveForward()) { + carMovedArray[j] += "-"; + } + Console.print(`${carNames[j]} : ${carMovedArray[j]}`); + } + return carMovedArray; + } + /** + * 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; + } + + async run() { + await this.runStageReceiveCarNames(); + + await this.runStageReceiveIterateNumber(); + + const result = await this.runStageStartRace( + this.iterationCount, + this.carNames + ); Console.print(`최종 우승자 : pobi, jun`); } From 017f47fa2976201723b84569b38150a2e91bb85e Mon Sep 17 00:00:00 2001 From: sungeunp Date: Sun, 26 Oct 2025 03:41:33 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=EA=B0=81=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20(4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실행 결과를 보고 단독 우승자 혹은 다수의 우승자를 출력한다. - 프로그램의 안정성을 위해 native method를 최대한 사용하려고 했다. 이를 위해 Math.max, Array,join 등의 method를 사용하였다. --- src/App.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/App.js b/src/App.js index 4b242450..341a4689 100644 --- a/src/App.js +++ b/src/App.js @@ -100,6 +100,17 @@ class App { return carMovedArray; } + 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() { await this.runStageReceiveCarNames(); @@ -110,7 +121,7 @@ class App { this.carNames ); - Console.print(`최종 우승자 : pobi, jun`); + this.runStagePrintWinner(result, this.carNames); } } From b89dc73217057dcf555c8ebcbf1e30b1a0964019 Mon Sep 17 00:00:00 2001 From: sungeunp Date: Mon, 27 Oct 2025 16:43:24 +0900 Subject: [PATCH 10/15] =?UTF-8?q?refactor(all):=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 요구사항에 제시된 코드 컨벤션(네이밍, 객체지향 원칙)을 적용하였습니다. - String 형에서 Number로 parsing하는 과정을 안정화 하였습니다. - App의 run method에 exception catch를 추가하여 main method에서 커버하도록 수정하였습니다. - 누락된 주석을 추가했습니다. --- src/App.js | 64 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/src/App.js b/src/App.js index 341a4689..220a4c44 100644 --- a/src/App.js +++ b/src/App.js @@ -1,6 +1,7 @@ import { Console, MissionUtils } from "@woowacourse/mission-utils"; class App { static ERROR_TITLE = "[ERROR]"; + static restriction_carLength = 5; /** * State variables @@ -8,29 +9,38 @@ class App { carNames; iterationCount; + constructor() { + this.carNames = []; + this.iterationCount = 0; + } + // 사용자가 입력한 law car names string을 restriction을 적용하고 validate 하여 배열로 반환합니다. - static restriction_carLength = 5; validateCarNames(carNames) { if (!carNames || typeof carNames !== "string") return []; const splitedResult = carNames.split(","); - return splitedResult.filter((item) => { - if (item.length < 1) { + + splitedResult.forEach((item) => { + const trimmedItem = item.trim(); + if (trimmedItem.length < 1) { throw new Error( `${App.ERROR_TITLE} 경주할 자동차의 이름이 비어있습니다.` ); } - if (item.length > App.restriction_carLength) { + if (trimmedItem.length > App.restriction_carLength) { throw new Error( `${App.ERROR_TITLE} 경주할 자동차의 이름이 5자가 넘습니다. (${item})` ); } - return item.trim() !== ""; }); + + return splitedResult + .map((name) => name.trim()) + .filter((name) => name.length > 0); } // 레이싱 게임의 규칙에 따라 렌덤 값이 4 이상인 경우 true를 반환하여 move forward를 허용한다. - getMoveForward() { + isMoveForward() { const randomValue = MissionUtils.Random.pickNumberInRange(0, 9); if (randomValue >= 4) { return true; @@ -60,25 +70,29 @@ class App { "시도할 횟수는 몇 회인가요?\n" ); // validate string `unsafe_iterationCount` - try { - this.iterationCount = Number.parseInt(unsafeIterationCount); - } catch (e) { + const count = Number(unsafeIterationCount); + if (isNaN(count)) { throw new Error( `${App.ERROR_TITLE} 입력한 시도할 횟수가 숫자가 아닙니다. (${unsafeIterationCount})` ); } - // validate number `unsafe_iterationCount` - if (this.iterationCount < 1) { - throw new Error( - `${App.ERROR_TITLE} 시도할 횟수는 0이나 음수가 될 수 없습니다.` - ); - } + + if (count < 1) + if (count < 1 || !Number.isInteger(count)) { + // validate number `unsafe_iterationCount` + 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.getMoveForward()) { carMovedArray[j] += "-"; @@ -100,6 +114,9 @@ class App { return carMovedArray; } + /** + * Stage 4: 우승자를 출력합니다. + */ runStagePrintWinner(result, carNames) { const moveLengths = result.map((item) => item.length); const maxMoveLength = Math.max(...moveLengths); @@ -112,16 +129,17 @@ class App { } async run() { - await this.runStageReceiveCarNames(); + try { + await this.runStageReceiveCarNames(); + await this.runStageReceiveIterateNumber(); - await this.runStageReceiveIterateNumber(); + const result = this.runStageStartRace(this.iterationCount, this.carNames); - const result = await this.runStageStartRace( - this.iterationCount, - this.carNames - ); - - this.runStagePrintWinner(result, this.carNames); + this.runStagePrintWinner(result, this.carNames); + } catch (error) { + Console.print(error.message); + throw error; + } } } From 7c68e9b56b49f515eb7983be90a023b047a3c458 Mon Sep 17 00:00:00 2001 From: sungeunp Date: Mon, 27 Oct 2025 17:36:17 +0900 Subject: [PATCH 11/15] =?UTF-8?q?test(all):=20Jest=EB=A5=BC=20=EC=9D=B4?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레이싱 게임 전반을 커버하는 테스트 코드를 작성했습니다. - Stage별 모든 상황에 대한 테스트 코드와 레이싱 경기 시행에 대한 테스트 코드를 작성했습니다. --- src/__tests__/App.test.js | 161 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 src/__tests__/App.test.js diff --git a/src/__tests__/App.test.js b/src/__tests__/App.test.js new file mode 100644 index 00000000..be011d14 --- /dev/null +++ b/src/__tests__/App.test.js @@ -0,0 +1,161 @@ +import App from "../App.js"; +import { MissionUtils } from "@woowacourse/mission-utils"; + +const mockReadLineAsync = jest.fn(); +const mockPrint = jest.fn(); +const mockPickNumberInRange = jest.fn(); + +MissionUtils.Console.readLineAsync = mockReadLineAsync; +MissionUtils.Console.readLineAsync = mockReadLineAsync; +MissionUtils.Console.readLineAsync = mockReadLineAsync; + +describe("자동차 경주 게임 테스트", () => { + let app; + + beforeEach(() => { + app = new App(); + mockReadLineAsync.mockClear(); + mockPrint.mockClear(); + mockPickNumberInRange.mockClear(); + }); + + describe("경주에 참가하는 자동차 이름들의 유효성 검사", () => { + test("이름이 5자를 초과할 시 예외를 발생시킨다.", () => { + const longName = "pobi,woni,jun,seongeun"; + expect(() => app.validateCarNames(longName)).toThrow("[ERROR]"); + }); + + test(",(comma)사이 이름이 비어있는 경우 예외를 발생시킨다.", () => { + const emptyName = "pobi,,jun"; + expect(() => app.validateCarNames(emptyName)).toThrow("[ERROR]"); + }); + + test("runStageReceiveCarNames - 유효한 이름을 입력하면 this.carNames에 저장된다.", async () => { + const carNames = "pobi,woni,jun"; + mockReadLineAsync.mockResolvedValue(carNames); + await app.runStageReceiveCarNames(); + expect(app.carNames).toEqual(["pobi", "woni", "jun"]); + }); + + test("runStageReceiveCarNames - 경주에 참가하는 유효한 자동차가 1개 미만이면 예외를 발생시킨다.", async () => { + mockReadLineAsync.mockResolvedValue(""); // 빈 문자열 입력 + // 비동기 함수의 예외는 rejects.toThrow로 테스트합니다. + await expect(app.runStageReceiveCarNames()).rejects.toThrow( + "[ERROR] 경주할 자동차가 없습니다." + ); + }); + }); + + describe("라운드 횟수에 대한 유효성 검사", () => { + test("숫자가 아닌 값을 입력하면 예외를 발생시킨다.", async () => { + mockReadLineAsync.mockResolvedValue("abc"); + await expect(app.runStageReceiveIterateNumber()).rejects.toThrow( + "[ERROR]" + ); + }); + + test("1 미만의 숫자를 입력하면 예외를 발생시킨다.", async () => { + mockReadLineAsync.mockResolvedValue("0"); + await expect(app.runStageReceiveIterateNumber()).rejects.toThrow( + "[ERROR]" + ); + }); + + test("정수가 아닌 숫자를 입력하면 예외를 발생시킨다.", async () => { + mockReadLineAsync.mockResolvedValue("1.5"); + await expect(app.runStageReceiveIterateNumber()).rejects.toThrow( + "[ERROR]" + ); + }); + + test("유효한 횟수를 입력하면 this.iterationCount에 저장된다.", async () => { + mockReadLineAsync.mockResolvedValue("5"); + await app.runStageReceiveIterateNumber(); + expect(app.iterationCount).toBe(5); + }); + }); + + 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("단독 우승자를 올바르게 출력한다. (pobi가 우승했다고 가정했을 시)", () => { + const carNames = ["pobi", "woni", "jun"]; + const result = ["---", "-", "--"]; + app.runStagePrintWinner(result, carNames); + + expect(mockPrint).toHaveBeenLastCalledWith("최종 우승자 : pobi"); + }); + + test("공동 우승자를 쉼표로 구분하여 올바르게 출력한다. (동률이 나와서 pobi, jun이 공동 우승이 될 시)", () => { + const carNames = ["pobi", "woni", "jun"]; + const result = ["---", "-", "---"]; // pobi, jun 공동 우승 + app.runStagePrintWinner(result, carNames); + + expect(mockPrint).toHaveBeenLastCalledWith("최종 우승자 : pobi, jun"); + }); + }); + + describe("레이싱 경기 실행", () => { + test("게임이 정상적으로 실행되고 결과를 올바르게 출력한다.", async () => { + // 1. 값 입력 + mockReadLineAsync + .mockResolvedValueOnce("pobi,woni") // 자동차 이름 + .mockResolvedValueOnce("2"); // 시도 횟수 + + // 2. 랜덤 값 실행 (총 2라운드로 가정, 4번 시행) + mockPickNumberInRange + .mockReturnValueOnce(5) // pobi 1칸 전진 + .mockReturnValueOnce(1) // woni 정지 + .mockReturnValueOnce(0) // pobi 정지 + .mockReturnValueOnce(4); // woni 1칸 전진 + + // 3. 게임 실행 + await app.run(); + + // 4. 출력 결과 검증 + const expectedPrints = [ + "\n실행 결과", + "pobi : -", // 1라운드 pobi + "woni : ", // 1라운드 woni + "", // 1라운드 끝 + "pobi : -", // 2라운드 pobi + "woni : -", // 2라운드 woni + "", // 2라운드 끝 + "최종 우승자 : pobi, woni", // 최종 결과 + ]; + + // mockPrint.mock.calls는 [ [arg1], [arg2], ... ] 형태의 2차원 배열입니다. + const actualPrints = mockPrint.mock.calls.map((call) => call[0]); + + expect(actualPrints).toEqual(expectedPrints); + }); + + test("예외 발생 시 [ERROR] 메시지를 출력하고 다시 throw한다.", async () => { + // 5자를 넘는 'seongeun' 자동차 이름 입력 + mockReadLineAsync.mockResolvedValue("seongeun,pobi"); + + await expect(app.run()).rejects.toThrow("[ERROR]"); + + // 에러 메시지 출력을 확인 + expect(mockPrint).toHaveBeenCalledWith( + expect.stringContaining("[ERROR]") + ); + }); + }); +}); From 910c9e814d4520525ae81b4a723f396187396682 Mon Sep 17 00:00:00 2001 From: sungeunp Date: Mon, 27 Oct 2025 22:51:28 +0900 Subject: [PATCH 12/15] =?UTF-8?q?docs(readme):=20readme=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테스트 코드에 통과하기 위해 UX탐구를 통한 Output 수정을 할 수 없었음으로 UX탐구 단계는 제거합니다. --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index aad1c8d8..2c2a9ef3 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,3 @@ ### > 각 단계 로직 구현 (4) - 실행 결과를 보고 단독 우승자 혹은 다수의 우승자를 출력한다. - -### > 사용자 탐구 및 편의성 개선 - -- `WIP` From 3fe4b76adc2fc359960a42832746df3cff25aa08 Mon Sep 17 00:00:00 2001 From: sungeunp Date: Mon, 27 Oct 2025 22:52:56 +0900 Subject: [PATCH 13/15] =?UTF-8?q?fix(app):=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 중복 로직 삭제 - 잘못 구현돼있던 로직 수정 --- src/App.js | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/App.js b/src/App.js index 220a4c44..f042a20d 100644 --- a/src/App.js +++ b/src/App.js @@ -69,7 +69,7 @@ class App { const unsafeIterationCount = await Console.readLineAsync( "시도할 횟수는 몇 회인가요?\n" ); - // validate string `unsafe_iterationCount` + // validate string `unsafeIterationCount` const count = Number(unsafeIterationCount); if (isNaN(count)) { throw new Error( @@ -77,13 +77,12 @@ class App { ); } - if (count < 1) - if (count < 1 || !Number.isInteger(count)) { - // validate number `unsafe_iterationCount` - throw new Error( - `${App.ERROR_TITLE} 시도할 횟수는 1 이상의 정수여야 합니다.` - ); - } + if (count < 1 || !Number.isInteger(count)) { + // validate number `unsafeIterationCount` + throw new Error( + `${App.ERROR_TITLE} 시도할 횟수는 1 이상의 정수여야 합니다.` + ); + } this.iterationCount = count; } @@ -94,12 +93,12 @@ class App { iterateCars(carNames, carMovedArray) { const newCarMovedArray = [...carMovedArray]; for (let j = 0; j < carNames.length; j++) { - if (this.getMoveForward()) { - carMovedArray[j] += "-"; + if (this.isMoveForward()) { + newCarMovedArray[j] += "-"; } - Console.print(`${carNames[j]} : ${carMovedArray[j]}`); + Console.print(`${carNames[j]} : ${newCarMovedArray[j]}`); } - return carMovedArray; + return newCarMovedArray; } /** * Stage 3: 게임을 플레이합니다. From 35e15e04ab7f4f6243fe705652431e8347a79317 Mon Sep 17 00:00:00 2001 From: sungeunp Date: Mon, 27 Oct 2025 22:53:52 +0900 Subject: [PATCH 14/15] =?UTF-8?q?Revert=20"test(all):=20Jest=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=B4=EC=9A=A9=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 7c68e9b56b49f515eb7983be90a023b047a3c458. --- src/__tests__/App.test.js | 161 -------------------------------------- 1 file changed, 161 deletions(-) delete mode 100644 src/__tests__/App.test.js diff --git a/src/__tests__/App.test.js b/src/__tests__/App.test.js deleted file mode 100644 index be011d14..00000000 --- a/src/__tests__/App.test.js +++ /dev/null @@ -1,161 +0,0 @@ -import App from "../App.js"; -import { MissionUtils } from "@woowacourse/mission-utils"; - -const mockReadLineAsync = jest.fn(); -const mockPrint = jest.fn(); -const mockPickNumberInRange = jest.fn(); - -MissionUtils.Console.readLineAsync = mockReadLineAsync; -MissionUtils.Console.readLineAsync = mockReadLineAsync; -MissionUtils.Console.readLineAsync = mockReadLineAsync; - -describe("자동차 경주 게임 테스트", () => { - let app; - - beforeEach(() => { - app = new App(); - mockReadLineAsync.mockClear(); - mockPrint.mockClear(); - mockPickNumberInRange.mockClear(); - }); - - describe("경주에 참가하는 자동차 이름들의 유효성 검사", () => { - test("이름이 5자를 초과할 시 예외를 발생시킨다.", () => { - const longName = "pobi,woni,jun,seongeun"; - expect(() => app.validateCarNames(longName)).toThrow("[ERROR]"); - }); - - test(",(comma)사이 이름이 비어있는 경우 예외를 발생시킨다.", () => { - const emptyName = "pobi,,jun"; - expect(() => app.validateCarNames(emptyName)).toThrow("[ERROR]"); - }); - - test("runStageReceiveCarNames - 유효한 이름을 입력하면 this.carNames에 저장된다.", async () => { - const carNames = "pobi,woni,jun"; - mockReadLineAsync.mockResolvedValue(carNames); - await app.runStageReceiveCarNames(); - expect(app.carNames).toEqual(["pobi", "woni", "jun"]); - }); - - test("runStageReceiveCarNames - 경주에 참가하는 유효한 자동차가 1개 미만이면 예외를 발생시킨다.", async () => { - mockReadLineAsync.mockResolvedValue(""); // 빈 문자열 입력 - // 비동기 함수의 예외는 rejects.toThrow로 테스트합니다. - await expect(app.runStageReceiveCarNames()).rejects.toThrow( - "[ERROR] 경주할 자동차가 없습니다." - ); - }); - }); - - describe("라운드 횟수에 대한 유효성 검사", () => { - test("숫자가 아닌 값을 입력하면 예외를 발생시킨다.", async () => { - mockReadLineAsync.mockResolvedValue("abc"); - await expect(app.runStageReceiveIterateNumber()).rejects.toThrow( - "[ERROR]" - ); - }); - - test("1 미만의 숫자를 입력하면 예외를 발생시킨다.", async () => { - mockReadLineAsync.mockResolvedValue("0"); - await expect(app.runStageReceiveIterateNumber()).rejects.toThrow( - "[ERROR]" - ); - }); - - test("정수가 아닌 숫자를 입력하면 예외를 발생시킨다.", async () => { - mockReadLineAsync.mockResolvedValue("1.5"); - await expect(app.runStageReceiveIterateNumber()).rejects.toThrow( - "[ERROR]" - ); - }); - - test("유효한 횟수를 입력하면 this.iterationCount에 저장된다.", async () => { - mockReadLineAsync.mockResolvedValue("5"); - await app.runStageReceiveIterateNumber(); - expect(app.iterationCount).toBe(5); - }); - }); - - 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("단독 우승자를 올바르게 출력한다. (pobi가 우승했다고 가정했을 시)", () => { - const carNames = ["pobi", "woni", "jun"]; - const result = ["---", "-", "--"]; - app.runStagePrintWinner(result, carNames); - - expect(mockPrint).toHaveBeenLastCalledWith("최종 우승자 : pobi"); - }); - - test("공동 우승자를 쉼표로 구분하여 올바르게 출력한다. (동률이 나와서 pobi, jun이 공동 우승이 될 시)", () => { - const carNames = ["pobi", "woni", "jun"]; - const result = ["---", "-", "---"]; // pobi, jun 공동 우승 - app.runStagePrintWinner(result, carNames); - - expect(mockPrint).toHaveBeenLastCalledWith("최종 우승자 : pobi, jun"); - }); - }); - - describe("레이싱 경기 실행", () => { - test("게임이 정상적으로 실행되고 결과를 올바르게 출력한다.", async () => { - // 1. 값 입력 - mockReadLineAsync - .mockResolvedValueOnce("pobi,woni") // 자동차 이름 - .mockResolvedValueOnce("2"); // 시도 횟수 - - // 2. 랜덤 값 실행 (총 2라운드로 가정, 4번 시행) - mockPickNumberInRange - .mockReturnValueOnce(5) // pobi 1칸 전진 - .mockReturnValueOnce(1) // woni 정지 - .mockReturnValueOnce(0) // pobi 정지 - .mockReturnValueOnce(4); // woni 1칸 전진 - - // 3. 게임 실행 - await app.run(); - - // 4. 출력 결과 검증 - const expectedPrints = [ - "\n실행 결과", - "pobi : -", // 1라운드 pobi - "woni : ", // 1라운드 woni - "", // 1라운드 끝 - "pobi : -", // 2라운드 pobi - "woni : -", // 2라운드 woni - "", // 2라운드 끝 - "최종 우승자 : pobi, woni", // 최종 결과 - ]; - - // mockPrint.mock.calls는 [ [arg1], [arg2], ... ] 형태의 2차원 배열입니다. - const actualPrints = mockPrint.mock.calls.map((call) => call[0]); - - expect(actualPrints).toEqual(expectedPrints); - }); - - test("예외 발생 시 [ERROR] 메시지를 출력하고 다시 throw한다.", async () => { - // 5자를 넘는 'seongeun' 자동차 이름 입력 - mockReadLineAsync.mockResolvedValue("seongeun,pobi"); - - await expect(app.run()).rejects.toThrow("[ERROR]"); - - // 에러 메시지 출력을 확인 - expect(mockPrint).toHaveBeenCalledWith( - expect.stringContaining("[ERROR]") - ); - }); - }); -}); From c58c83650051f9577c6087a1b30951a51c8ff2a1 Mon Sep 17 00:00:00 2001 From: sungeunp Date: Mon, 27 Oct 2025 23:22:14 +0900 Subject: [PATCH 15/15] =?UTF-8?q?test(app):=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 각 기능별 테스트 코드를 작성하였다. --- src/__tests__/App.test.js | 126 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/__tests__/App.test.js 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]") + ); + }); + }); +});