diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..fb72f754c --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,31 @@ +export default { + env: { + browser: true, + es2021: true, + }, + extends: ['airbnb-base', 'prettier'], + overrides: [ + { + env: { + jest: true, + node: true, + }, + files: [ + '.eslintrc.{js,cjs}', + ], + parserOptions: { + sourceType: 'script', + }, + }, + ], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + 'max-depth': ['error', 2], + 'max-params': ['error', 3], + 'max-lines-per-function': ['error', { max: 10 }], + 'import/extensions': 'off', + }, +}; diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 000000000..1b4ce05b2 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,11 @@ +export default { + singleQuote: true, + semi: true, + useTabs: false, + tabWidth: 2, + trailingComma: "all", + printWidth: 80, + bracketSpacing: true, + arrowParens: "always", + endOfLine: "auto", +}; diff --git a/__tests__/BeforeRefactor.test.js b/__tests__/BeforeRefactor.test.js new file mode 100644 index 000000000..137f8da46 --- /dev/null +++ b/__tests__/BeforeRefactor.test.js @@ -0,0 +1,341 @@ +import App from "../src/App.js"; +import { Console } from "@woowacourse/mission-utils" +import { Random } from "@woowacourse/mission-utils" + +const mockInput = (input) => { + Console.readLineAsync = jest.fn(() => Promise.resolve(input)); +}; + +describe("App class", () => { + describe("getCarName method", () => { + test("사용자 입력", async () => { + const testInput = "pobi,woni,jun"; + mockInput(testInput); + + const app = new App(); + const result = await app.getCarName(); + + expect(Console.readLineAsync).toHaveBeenCalledTimes(1); + expect(Console.readLineAsync).toHaveBeenCalledWith("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)\n"); + expect(result).toEqual(["pobi", "woni", "jun"]); + }); + }); + + describe("sanitizeCarName method", () => { + let app; + beforeEach(() => { + app = new App(); + }); + + test("이름 사이에 공백이 없을 때", () => { + const input = "pobi,woni,jun"; + const result = app.sanitizeCarName(input); + expect(result).toEqual(["pobi", "woni", "jun"]); + }); + + test("이름 앞뒤에 공백이 있을 때", () => { + const input = " pobi ,woni , jun "; + const result = app.sanitizeCarName(input); + expect(result).toEqual(["pobi", "woni", "jun"]); + }); + + test("공백만 있는 이름이 있을 때", () => { + const input = "pobi, , jun"; + const result = app.sanitizeCarName(input); + expect(result).toEqual(["pobi", "jun"]); + }); + + test("연속적으로 여러 개의 콤마가 나올 때", () => { + const input = "pobi,,,woni,,jun"; + const result = app.sanitizeCarName(input); + expect(result).toEqual(["pobi", "woni", "jun"]); + }); + }); + + describe("Error Handling in validateCarName", () => { + let app; + beforeEach(() => { + app = new App(); + }); + + test("자동차 이름이 5자를 초과할 때", async () => { + mockInput("pobipobi,woni,jun"); + await expect(app.getCarName()).rejects.toThrow("[ERROR] 자동차 이름은 5자를 초과할 수 없습니다."); + }); + + test("자동차 이름이 중복될 때", async () => { + mockInput("pobi,pobi,jun"); + await expect(app.getCarName()).rejects.toThrow("[ERROR] 중복되는 자동차 이름이 있습니다."); + }); + + test("자동차 이름이 최소 2대 미만일 때", async () => { + mockInput("pobi"); + await expect(app.getCarName()).rejects.toThrow("[ERROR] 최소 두 대 이상의 자동차 이름을 입력해주세요."); + }); + }); + + describe("getRound method", () => { + let app; + beforeEach(() => { + app = new App(); + }); + + test("사용자로부터 시도할 횟수 입력 받기", async () => { + const testInput = "5"; + mockInput(testInput); + + const result = await app.getRoundNumber(); + + expect(Console.readLineAsync).toHaveBeenCalledWith("시도할 횟수는 몇 회인가요?\n"); + expect(result).toBe(5); + }); + }); + + describe("sanitizeRoundNumber method", () => { + let app; + beforeEach(() => { + app = new App(); + }); + + test("숫자에 공백이 없을 때", () => { + const input = "3"; + const result = app.sanitizeRoundNumber(input); + expect(result).toEqual(3); + }); + + test("앞뒤에 공백이 있을 때", () => { + const input = " 3 "; + const result = app.sanitizeRoundNumber(input); + expect(result).toEqual(3); + }); + + test("숫자 사이에 공백이 있을 때", () => { + const input = "1 0"; + const result = app.sanitizeRoundNumber(input); + expect(result).toEqual(10); + }); + }); + + describe("Error Handling in validateRound", () => { + let app; + beforeEach(() => { + app = new App(); + }); + + test("시도 횟수가 1 미만일 때", async () => { + mockInput("0"); + await expect(app.getRoundNumber()).rejects.toThrow("[ERROR] 최소 1 이상의 횟수를 입력해주세요."); + }); + + test("시도 횟수가 10 초과일 때", async () => { + mockInput("11"); + await expect(app.getRoundNumber()).rejects.toThrow("[ERROR] 최대 10 이하의 횟수를 입력해주세요."); + }); + + test("시도 횟수가 자연수 형식이 아닐 때", async () => { + mockInput("2.5"); + await expect(app.getRoundNumber()).rejects.toThrow("[ERROR] 횟수는 자연수 형식이어야 합니다"); + }); + + test("시도 횟수가 문자일 때", async () => { + mockInput("삼"); + await expect(app.getRoundNumber()).rejects.toThrow("[ERROR] 횟수는 자연수 형식이어야 합니다"); + }); + }); + + describe("makeCar method", () => { + let app; + beforeEach(() => { + app = new App(); + }); + + test("자동차 생성하기", () => { + const carName = ["pobi", "woni", "jun"]; + app.makeCar(carName); + + expect(app.car).toEqual([ + { name: "pobi", distance: 0, move: expect.any(Function), shouldMove: expect.any(Function) }, + { name: "woni", distance: 0, move: expect.any(Function), shouldMove: expect.any(Function) }, + { name: "jun", distance: 0, move: expect.any(Function), shouldMove: expect.any(Function) } + ]); + }); + }); + + describe("getRandomNumber method", () => { + let app; + beforeEach(() => { + app = new App(); + }); + + test("0 ~ 9 사이 무작위 값 생성 테스트", () => { + const results = new Set(); + + for (let i = 0; i < 100; i++) { + const randomValue = app.getRandomNumber(); + results.add(randomValue); + + expect(randomValue).toBeGreaterThanOrEqual(0); + expect(randomValue).toBeLessThanOrEqual(9); + } + + expect(results.size).toBe(10); + }); + }); + + describe("makeCar method movement test", () => { + let app; + let car; + beforeEach(() => { + app = new App(); + const carName = ["testCar"]; + app.makeCar(carName); + car = app.car[0]; + }); + + test("랜덤 숫자가 4 이상이면 전진", () => { + jest.spyOn(Random, 'pickNumberInRange').mockReturnValue(4); + expect(car.shouldMove()).toBeTruthy(); + + jest.spyOn(Random, 'pickNumberInRange').mockReturnValue(5); + expect(car.shouldMove()).toBeTruthy(); + + jest.spyOn(Random, 'pickNumberInRange').mockReturnValue(9); + expect(car.shouldMove()).toBeTruthy(); + }); + + test("랜덤 숫자가 4 미만이면 정지", () => { + jest.spyOn(Random, 'pickNumberInRange').mockReturnValue(1); + expect(car.shouldMove()).toBeFalsy(); + + jest.spyOn(Random, 'pickNumberInRange').mockReturnValue(2); + expect(car.shouldMove()).toBeFalsy(); + + jest.spyOn(Random, 'pickNumberInRange').mockReturnValue(3); + expect(car.shouldMove()).toBeFalsy(); + }); + }); + + describe("runRace method", () => { + let app; + beforeEach(() => { + app = new App(); + }); + + test("자동차는 총 횟수 만큼 움직여야 한다", async () => { + jest.spyOn(app, 'getCarName').mockResolvedValue(["pobi"]); + jest.spyOn(app, 'getRoundNumber').mockResolvedValue(3); + jest.spyOn(Console, 'readLineAsync').mockResolvedValue(""); + + jest.spyOn(app, 'getRandomNumber').mockReturnValue(4); + + await app.play(); + + const moveCount = app.car[0].distance; + + expect(moveCount).toBe(3); + }); + }); + + describe("getWinner method", () => { + let app; + beforeEach(() => { + app = new App(); + }); + + test("단독 우승자 결정하기", () => { + const carName = ["pobi", "woni", "jun"]; + app.makeCar(carName); + + jest.spyOn(Random, 'pickNumberInRange').mockReturnValueOnce(4) + .mockReturnValueOnce(4) + .mockReturnValueOnce(4) + .mockReturnValueOnce(4) + .mockReturnValueOnce(3); + + app.runRace(3); + + const winner = app.getWinner(); + expect(winner).toEqual(["pobi"]); + }); + + test("다중 우승자 결정하기", () => { + const carName = ["pobi", "woni", "jun"]; + app.makeCar(carName); + + jest.spyOn(Random, 'pickNumberInRange').mockReturnValueOnce(4) + .mockReturnValueOnce(4) + .mockReturnValueOnce(4) + .mockReturnValueOnce(4) + .mockReturnValueOnce(4) + .mockReturnValueOnce(4) + .mockReturnValueOnce(4) + .mockReturnValueOnce(4) + .mockReturnValueOnce(4); + + app.runRace(3); + + const winner = app.getWinner(); + expect(winner).toEqual(["pobi", "woni", "jun"]); + }); + }); + + describe("printResultHeader method", () => { + let app; + beforeEach(() => { + app = new App(); + jest.spyOn(Console, 'print'); + }); + + test("실행 결과 출력", () => { + app.printResultHeader(); + expect(Console.print).toHaveBeenCalledWith("\n실행 결과"); + }); + }); + + describe("printRoundResult method", () => { + let app; + beforeEach(() => { + app = new App(); + }); + + test("차수별 실행 결과 출력", () => { + const carName = ["pobi", "woni", "jun"]; + app.makeCar(carName); + jest.spyOn(Console, 'print').mockImplementation(() => {}); + + app.car[0].distance = 3; // pobi + app.car[1].distance = 2; // woni + app.car[2].distance = 1; // jun + + app.printRoundResult(); + + expect(Console.print).toHaveBeenCalledWith("pobi : ---"); + expect(Console.print).toHaveBeenCalledWith("woni : --"); + expect(Console.print).toHaveBeenCalledWith("jun : -"); + }); + }); + + describe("printWinner method", () => { + let app; + beforeEach(() => { + app = new App(); + }); + + test("최종 우승자 메세지 출력", () => { + app.car = [ + { name: "pobi", distance: 5 }, + { name: "woni", distance: 5 }, + { name: "jun", distance: 3 } + ]; + + const printSpy = jest.spyOn(Console, 'print'); + + const winners = app.getWinner(); + app.printWinner(winners); + + expect(printSpy).toHaveBeenCalledWith("최종 우승자 : pobi, woni"); + + printSpy.mockRestore(); + }); + }); +}); \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..0db87d98c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,156 @@ +# 🚗 자동차 경주 게임 🚗 + +``` html + 경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분) + pobi,woni,jun + 시도할 횟수는 몇 회인가요? + 5 + + 실행 결과 + pobi : - + woni : + jun : - + + pobi : -- + woni : - + jun : -- + + pobi : --- + woni : -- + jun : --- + + pobi : ---- + woni : --- + jun : ---- + + pobi : ----- + woni : ---- + jun : ----- + + 최종 우승자 : pobi, jun + +``` + +--- + +## ✅ 기능 구현 목록 + +1. [] 자동차 이름 입력 받기 + - 1-1. [✔️] 이름 입력 메세지 출력 + - ex) "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)" + - 1-2. [✔️] 이름 입력 처리 + - 이름 입력은 쉼표로 구분 + - 이름에 공백이 들어갈 경우 제거 + - 빈 문자열 제거 + - ex) pobi,woni,jun + - 1-3. [✔️] 이름 입력 예외 처리 + - 이름이 두 개 미만인 경우 + - 각 이름이 5글자 초과인 경우 + - 중복되는 자동차 이름이 있는 경우 + - throw문을 사용해 "[ERROR]" 로 시작하는 메세지를 가지는 예외 발생 + - 애플리케이션 즉시 종료 + +2. [] 시도 횟수 입력 받기 + - 2-1. [✔️] 시도 횟수 입력 메세지 출력 + - ex) "시도할 횟수는 몇 회인가요?" + - 2-2. [✔️] 시도 횟수 입력 처리 + - 공백이 들어갈 경우 제거 + - ex) 3 + - 2-3. [✔️] 시도 횟수 입력 예외처리 + - 1보다 작은 숫자를 입력한 경우 + - 자연수가 아닌 형식을 입력한 경우 + - 시도 횟수가 10 이상인 경우(임의 설정) + - throw문을 사용해 "[ERROR]" 로 시작하는 메세지를 가지는 예외 발생 + - 애플리케이션 즉시 종료 + +3. [] 자동차 만들기 + - 3-1. [✔️] 입력 값에 따라 자동차 생성 + +4. [] 자동차 경주 + - 4-1. [✔️] 0 ~ 9 사이 무작위 값 생성 + - 4-2. [✔️] 4 이상일 경우 한칸 전진 + - 4-3. [✔️] 시도 횟수 만큼 경주 진행 + - 4-4. [✔️] 마지막 경주로 최종 우승자 구하기 + - 우승자 한 명 이상 가능 + +5. [] 실행 결과 나타내기 + - 5-1. [✔️] 실행 결과 메세지 출력 + - "실행 결과" + - 5-2. [✔️] 각 차수별 실행 결과 메세지 출력 + +6. [] 최종 우승자 나타내기 + - 6-1. [✔️] 최종 우승자 메세지 출력 + - 우승자가 여려명일 경우 쉼표로 구분 + - ex) 최종 우승자 : pobi, jun + +--- + +## ✔️ 테스트 구현 목록 + +1. [] 자동차 이름 입력 받기 + - T-1-1. [✔️] 이름 입력 메세지 출력 테스트 + - T-1-2. [✔️] 이름 입력 처리 테스트 + - T-1-3. [✔️] 이름 입력 예외처리 테스트 + +2. [] 시도 횟수 입력 받기 + - T-2-1. [✔️] 시도 횟수 입력 메세지 출력 테스트 + - T-2-2. [✔️] 시도 횟수 입력 처리 테스트 + - T-2-3. [✔️] 시도 횟수 입력 예외처리 테스트 + +3. [] 자동차 만들기 + - T-3-1. [✔️] 입력 값에 따른 자동차 생성 테스트 + +4. [] 자동차 경주 + - T-4-1. [✔️] 0 ~ 9 사이 무작위 값 생성 테스트 + - T-4-2. [✔️] 4 이상일 경우 한칸 전진 테스트 + - T-4-3. [✔️] 시도 횟수 만큼 경주 진행 테스트 + - T-4-4. [✔️] 마지막 경주로 최종 우승자 구하기 테스트 + +5. [] 실행 결과 나타내기 + - T-5-1. [✔️] 실행 결과 메세지 출력 테스트 + - T-5-2. [✔️] 각 차수별 실행 결과 메세지 출력 테스트 + +6. [] 최종 우승자 나타내기 + - T-6-1. [✔️] 최종 우승자 메세지 출력 테스트 + +--- + +## 🖋️ 요구사항 + +- [] Node.js 18.17.1 버전에서 실행 가능해야 한다. +- [] 프로그램 실행의 시작점은 App.js의 play 메서드이다. +- [] package.json을 변경할 수 없다. +- [] 순수 Vanila JS로만 구현한다. +- [] 외부 라이브러리(jQuery, Lodash 등)를 사용하지 않는다. +- [] JavaScript 코드 컨벤션을 지키면서 프로그래밍 한다 +- [] 프로그램 종료 시 process.exit()를 호출하지 않는다 +- [] 프로그램 구현이 완료되면 ApplicationTest의 모든 테스트가 성공해야 한다 +- [] 요구 사항에서 달리 명시하지 않는 한 파일, 패키지 이름을 수정하거나 이동하지 않는다. +- [] indent(인덴트, 들여쓰기) 2까지만 허용한다. + - 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다. +- [] jest를 이용하여 본인이 정리한 기능 목록이 정상 동작함을 테스트 코드로 확인한다. +- [] @woowacourse/mission-utils에서 제공하는 Random 및 Console API를 사용하여 구현한다 +- [] Random 값 추출은 Random.pickNumberInRange()를 활용한다. +- [] 사용자의 값을 입력 받고 출력하기 위해서는 Console.readLineAsync, Console.print를 활용한다 + +--- + +## 📢 1주차 공통 피드백 + +- 요구사항을 정확히 준수한다 +- 커밋 메시지를 의미 있게 작성한다 +- git을 통해 관리할 자원에 대해서도 고려한다 +- Pull Request를 보내기 전 브랜치를 확인한다 +- PR을 한 번 작성했다면 닫지 말고 추가 커밋을 한다 +- 이름을 통해 의도를 드러낸다 +- 축약하지 않는다 +- 공백도 코딩 컨벤션이다 +- 공백 라인을 의미 있게 사용한다 +- space와 tab을 혼용하지 않는다 +- 의미 없는 주석을 달지 않는다 +- linter와 Code Formatter의 기능을 활용한다 +- EOL(End Of Line) +- 불필요한 console.log를 남기지 않는다 +- JavaScript에서 제공하는 API를 적극 활용한다 + +--- diff --git a/docs/StudyLog.md b/docs/StudyLog.md new file mode 100644 index 000000000..25a35e1b5 --- /dev/null +++ b/docs/StudyLog.md @@ -0,0 +1,548 @@ +# 📖 배운 내용 📖 + +## 1. 자동차 이름 입력 받기 + +
+ +### 1-1. 이름 입력 메세지 출력 + +- `Class` + - 객체 지향 프로그래밍에서, 객체를 생성하기 위한 '틀' 또는 '설계도'의 개념 + - 데이터(속성 or 멤버 변수) + 데이터를 조작하는 메서드(함수)를 포함한다 +- `Method` + - 클래스 내에서 정의된 함수 + - 객체가 수행할 수 있는 동작이나 행위 + - 객체의 속성(데이터)을 조작하거나 처리한다 + - '달린다', '멈춘다' 등 +- `this 키워드` + - 현재 객체 또는 현재 컨텍스트를 참조하는 키워드 +- `async 와 await` + - 비동기 처리 패턴으로 어떤 동작의 완료를 기다려야 할 때 사용한다 + - 예를 들어 사용자의 입력을 기다리는 작업등은 시간이 얼마나 걸릴지 모르므로 비동기로 처리한다 + - async - 함수가 비동기로 작동함을 선언한다 + - await - 비동기 동작의 완료를 기다린다 +- `readLine(query, callback)` + - 주어진 질문을 화면에 출력한다 + - 사용자가 답변을 입력할 때까지 기다린다 + - 이후 입력된 답변을 인수로 전달하는 콜백 함수를 호출한다 + + ``` javascript + Console.readLine('닉네임을 입력해주세요.', (answer) => { + console.log(`닉네임: ${answer}`); + }); + ``` + +- `readLineAsync(query)` + - 주어진 질문을 화면에 출력한다 + - 사용자가 입력한 답변을 Promise를 통해 반환한다 + + ``` javascript + async function getUername() { + try{ + const username = await Console.readLineAsync('닉네임을 입력해주세요.'); + } catch (error) { + // reject 되는 경우 + } + } + ``` + +- `print(message)` + - 주어진 문자열을 콘솔에 출력한다 + + ``` javascript + async function getUername() { + try{ + const username = await Console.readLineAsync('닉네임을 입력해주세요.'); + } catch (error) { + // reject 되는 경우 + } + } + ``` + +
+ +### T-1-1. 이름 입력 메세지 출력 테스트 + +- `Mocking` + - 실제 구현을 바꾸지 않고도 테스트의 격리를 보장하고 실행한다 +- `Spy` + - 함수나 메서드가 어떻게 호출되었는지 추적한다 +- `Mocking Clear` + - 각 테스트 사이에서 Mock과 Spy의 상태를 초기화한다 + - 다른 테스트의 결과에 영향을 주지 않기 위한 목적 +- `Describe` + - 관련 테스트들을 그룹화하는데 사용된다 + - 블록 안 테스트들이 특정 클래스나 함수, 기능 등에 관한 것이라는 것을 알려준다 +- `Test` + - 개별 테스트 케이스를 정의한다 + - 첫 번째 인자는 테스트의 이름 또는 설명을 문자열로 받는다 + - 두번째 인자는 테스트 로직을 포함하는 함수를 받는다 +- `Expectation` + - 값에 대한 특정 조건을 설정한다. + +
+ +### 1-2. 이름 입력 처리 + +- `this 키워드` + - 같은 클래스 내부의 다른 메서드나 속성을 접근할 수 있다 + - 인자를 해당 메서드에 넘겨줄 수 있다 +- `객체` + - 데이터와 관련된 기능을 함께 포함하는 항목을 의미 + - 생성의 기원이되는 정보 명시 (X) + - 모든 객체는 인스턴스 (X) +- `인스턴스` + - 클래스를 기반으로 생성된 메모리에 할당된 실제 구현체를 의미 + - 클래스 - 쿠키를 만드는 틀 + - 인스턴스 - 그 틀로 만든 실제 쿠키 + - 생성의 기원이 되는 클래스나 프로토타입에 대한 정보 명시 (O) + - 모든 인스턴스는 객체 (O) + - 생성 : const 인스턴스명 = new 클래스명(매개변수); + + ``` javascript + //Dog : 클래스, 설계도나 틀의 기능 + class Dog { + constructor(name) { + this.name = name; + } + + bark() { + console.log(`${this.name} says mung!`); + } + } + + //myDog : Dog 클래스를 바탕으로 실제 개를 생성 + const myDog = new Dog("Bobby"); + + //객체를 생성했으므로 bark의 기능 수행 가능 + //출력 : Bobby says mung! + myDog.bark(); + ``` + +- `화살표 함수` + - ES6에서 도입된 새로운 함수 표현 방식 + - 간결한 문법 - 본문이 한 줄인 경우 중괄호 및 'return'문 생략 가능 + - this바인딩 - 자신만의 this를 가지지 않고, 상위 스코프 this 참조 +- `전역 객체` + - 브라우저 : window 객체, 전역으로 선언된 변수나 함수 + - Node.js : global 객체, 명시적으로 할당된 변수나 함수 + - 모든 스코프에서 접근 가능하기 때문에 주의가 필요하다 +- `split` + - 문자열 메서드 + - 특정 구분자를 기준으로 문자열을 여러 부분으로 나누어 배열로 반환한다 + + ``` javascript + const str = "apple,banana,grape"; + const fruits = str.split(','); + // ["apple", "banana", "grape"] + ``` + +- `map` + - 배열 메서드 + - 배열의 각 요소에 대해 주어진 함수를 호출한다 + - 그 결과로 구성된 새로운 배열을 반환한다 + - 불변성 - 원본 배열을 수정하지 않고 새로운 배열을 반환한다 + - 순회 - 배열의 모든 요소에 대해 주어진 함수를 순서대로 실행한다 + +
+ +### T-1-2. 이름 입력 처리 테스트 + +- `matcher` + - 테스팅 라이브러리에서 제공하는 함수나 메서드 + - 실제 결과와 기대값을 비교하여 테스트의 성공 여부를 결정하는데 사용된다 +- `toBe` + - 기본 타입의 값이 일치하는지 확인한다 +- `not` + - matcher의 반대 결과를 반환한다 + - 주로 두 값이 다른지 확인하고 싶을 때 사용한다 +- `toBeTruthy` + - 값이 boolean에서 true로 평가되는지 확인한다 +- `toBeFalsy` + - 값이 boolean에서 false로 평가되는지 확인한다 +- `toContain` + - 배열이나 문자열이 특정 항목 또는 문자열을 포함하는지 확인한다 + +
+ +### 1-3. 이름 입력 예외 처리 + +- `Set의 개념` + - ES6에서 도입된 데이터 구조 + - 유일한 값만 저장되는 컬렉션으로 중복을 허용하지 않는다 +- `Set의 특징` + - 값은 유일하게 하나만 존재한다 + - 각 요소는 순서를 가지지 않는다 + - 배열과 유사하지만 중복을 허용하지 않는다는 점이 주요 차이점이다 +- `중복체크와 Set` + - 배열에 중복되는 이름이 있다면, Set으로 변환했을때의 길이와 원래 배열의 길이가 달라진다 + - Set은 중복을 허용하지 않기 때문에 중복 값을 제거하게 된다 + - 따라서 두 길이를 비교함으로써 원래 배열에 중복 요소가 있는지 확인할 수 있다 +- `_언더스코어, #` + - 일반적으로 private 또는 내부에서만 사용되는 메서드나 변수를 나타내기 위해 사용된다 + - 즉 변수나 메서드를 외부에서 접근하거나 사용하면 안된다는 의미를 전달한다 + - JavaScript에는 private키워드 대신 컨벤션으로 언더스코어를 사용해왔다 + - ES6부터는 #을 사용하여 private필드를 정의할 수 있게되었지만 많은 개발자들은 여전히 언더스코어 컨벤션을 사용한다. + - 에어비앤비 스타일 가이드는 언더스코어를 붙이는 것을 권장하지 않는다 + +
+ +### T-1-3. 이름 입력 예외 처리 테스트 + +- `Private 메서드 테스트` + - Priavate 메서드는 직접 테스트 할 수 없다 + - 간접적으로 public 메서드를 사용하여 테스트해야한다 + - 특히 private 메서드에 영향을 받는 public 메서드를 통해 그 효과를 확인 할 수 ㅣ있다 +- `비동기 에러 처리 테스트` + - await expect(...).rejects.toThrow(...) 구문을 사용한다 + + ``` javascript + test("비동기 에러 예상하기", async () => { + await expect(asyncFunction()).rejects.toThrow("예상되는 에러 메시지"); + }); + ``` + + - 이를 통해 비동기 함수가 특정 에러를 반환하는지 테스트 할 수 있다 + - 결론적으로, 직접적인 방법으로 테스트 할 수 없는 코드나 상황들에도 간접적인 방법을 통해 코드의 품질을 확보할 수 있다 + +
+ +--- + +## 2. 시도 횟수 입력 받기 + +
+ +### 2-1. 시도 횟수 입력 메세지 출력 + +- `parseInt` + - 문자열을 정수로 변환하는 함수 + - 사용자에게 숫자를 입력받도록 요청하더라도, 반환값은 문자열 형태가 된다 + - 그렇기 때문에 문자열을 실제 정수로 사용하기 위해, parseInt함수가 쓰인다 + - 형식은 다음과 같다 + + ``` javascript + parseInt(string, radix); + ``` + + - String - 변환될 문자열 + - radix - 기수, 문자열이 표시되는 진수를 나타내는 2에서 36사이 정수이다 + - ex) 10은 parseInt의 두 번째 인자로, 10진수로 변환하라는 의미이다 + - radix를 생략하면 문자열에 따라 다른 진수를 사용한다 + - 0x 또는 0X - 16진수로 간주 + - 0o - 8진수로 간주 + - 나머지 경우 - 기본적으로 10진수로 간주 + - 하지만 예상치 못한 결과를 방지하기 위해 radix 값을 항상 제공하는 것이 좋다 + +
+ +### T-2-1. 시도 횟수 입력 메세지 출력 테스트 + +- `toHaveBeen 관련 matcher` + - toHaveBeen으로 시작하는 matcher들은 특히 mock함수의 호출과 관련된 검증에 사용된다 +- `toHaveBeenCalled` + - mock 함수가 호출되었는지 확인한다 +- `toHaveBeenCalledTimes` + - mock 함수가 특정 횟수만큼 호출되었는지 확인한다 + + ``` javascript + expect(mockFunc).toHaveBeenCalledTimes(3); + // mockFunc이 3번 호출되었는지 확인 + ``` + +- `toHaveBeenCalledWith` + - mock 함수가 특정 인수로 호출되었는지 확인한다 + + ``` javascript + expect(mockFunc).toHaveBeenCalledWith(arg1, arg2); + // mockFunc이 arg1, arg2로 호출되었는지 확인 + ``` + +- `toHaveBeenLastCalledWith` + - mock 함수가 마지막으로 호출될 때 주어진 인수로 호출되었는지 확인한다 + + ``` javascript + expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2); + // mockFunc이 마지막으로 arg1, arg2로 호출되었는지 확인 + ``` + +- `toHaveBeenNthCalledWith` + - mock 함수가 n번째로 호출될 때 특정 인수로 호출되었는지 확인한다 + + ``` javascript + expect(mockFunc).toHaveBeenNthCalledWith(1, arg1, arg2); + // mockFunc이 첫 번째로 arg1, arg2로 호출되었는지 확인 + ``` + +- `toHaveReturned` + - mock 함수가 값을 반환했는지 확인한다 +- `toHaveReturnedWith` + - mock 함수가 특정 값을 반환했는지 확인한다 + + ``` javascript + expect(mockFunc).toHaveReturnedWith(returnValue); + // mockFunc이 returnValue를 반환했는지 확인 + ``` + +- `toHaveBeenLastReturnedWith` + - mock 함수가 마지막 호출에서 특정 값을 반환했는지 확인한다 + + ``` javascript + expect(mockFunc).toHaveBeenLastReturnedWith(returnValue); + // mockFunc의 마지막 호출에서 returnValue를 반환했는지 확인 + ``` + +- `toHaveNthReturnedWith` + - mock 함수의 n번째 호출에서 특정 값을 반환했는지 확인한다 + + ``` javascript + expect(mockFunc).toHaveNthReturnedWith(1, returnValue); + // mockFunc의 첫 번째 호출에서 returnValue를 반환했는지 확인 + ``` + +
+ +### 2-2. 시도 횟수 입력 처리 + +- `trim` + - 문자열의 앞 뒤 공백을 제거하기 위해 trim() 메서드를 사용한다 + + ``` javascript + const result = " example ".trim(); + ``` + +- `parseInt` + - 문자열 내에 숫자와 문자가 함께 있을 때 + - 숫자가 아닌 문자를 만나면 해당 문자까지만 파싱하고, 그 이후의 문자는 무시한다 + + ``` javascript + const number = parseInt("123abc", 10); + // number : 123 + ``` + + - 공백만 있는 문자열 일때 + - 결과값은 NaN이다 + + ``` javascript + const input = " "; + const result = parseInt(input.trim().replace(/\s+/g, ''), 10); + // result : NaN + ``` + +- `replace` + - 문자열의 일부를 다른 문자열로 대체하기 위해 사용한다 + - 두 가지 인자를 받는다 + - 검색 패턴 - 대체될 문자열 또는 정규 표현식 + - 대체 문자열 - 대체될 새로운 문자열 또는 함수 + + ``` javascript + const str = "Hello, World!"; + const newStr = str.replace("World", "Seojin"); + console.log(newStr); + // 출력: "Hello, Seojin!" + + ``` + +- `정규표현식` + - 문자열의 특정 패턴을 찾기 위한 도구이다 + - /.../로 둘러싸여 있는 형식으로 표현된다 + - \d: 모든 숫자(0-9와 동일) + - \s: 모든 공백 문자(스페이스, 탭, 줄바꿈 등) + - \w: 모든 단어 문자(a-z, A-Z, 0-9, _와 동일) + - g: 전역 검색, 문자열 내의 모든 일치 항목을 검색 + - i: 대소문자를 구분하지 않는 검색 + + ``` javascript + const str = "Hello 123 World 456!"; + const newStr = str.replace(/\d+/g, ''); + console.log(newStr); + // 출력: "Hello World !" + + ``` + +- 결론 + - 문자열을 적절하게 정제한 후 숫자로 변환하는 것이 효율적이다 + +
+ +### 2-3. 시도 횟수 입력 예외 처리 + +- `includes()` + - 문자열이 특정 부분 문자열을 포함하고 있는지 확인하기 위해 사용된다 + - 지정된 부분 문자열을 포함하면 true, 그렇지 않으면 false 반환 + - 정규 표현식 사용할 필요 없이 빠르게 확인할 수 있다 + + ``` javascript + string.includes(searchString, position) + ``` + + - searchString - 메인 문자열에서 찾을 부분 문자열 + - position - 선택 사항, 문자열 내에서 검색을 시작할 위치, 기본값 0 + +- `Number.isInteger()` + - 전달된 값이 정수인지 여부를 결정한다 + - 값이 정수면 true, 그렇지 않으면 false 반환 + - 사용자 입력이나 데이터 타입이 다양할 수 있는 데이터를 검증할 때 유용하다 + + ``` javascript + Number.isInteger(value) + ``` + + - value - 확인할 값 + +
+ +--- + +## 3. 자동차 만들기 + +
+ +### 3-1. 입력 값에 따른 자동차 생성 + +- `constructor` + - 생성자 + - 클래스 내에서 객체의 초기 상태를 설정하는 데 사용되는 특별한 메서드 + - 클래스를 통해 객체가 생성될 때 constructor가 자동으로 호출된다 + - 이를 통해 초기 속성 값을 설정하거나, 필요한 초기화 작업을 수행할 수 있다 + - 생성자는 필요에 따라 파라미터를 받을 수도 있다 + - 이를 통해 객체 생성 시 외부에서 값을 전달하여 객체의 상태를 설정할 수 있다 + + ``` javascript + class App { + constructor() { + this.car = []; + } + } + ``` + + - App 클래스의 constructor는 this.car라는 빈 배열을 초기화하고 있다 + - 이는 App 클래스의 인스턴스가 생성될 때마다 this.car가 빈 배열로 초기화된다는 것을 의미한다 + +
+ +--- + +## 4. 자동차 경주 + +
+ +### 4-1. 0 ~ 9 사이 무작위 값 생성 + +- `pickNumberInRange(startInclusive, endInclusive)` + - 숫자 범위를 지정하면 시작 또는 끝 숫자를 포함하여 범위의 숫자를 반환한다 + - 형식은 다음과 같다 + + ``` javascript + Random.pickNumberInRange(1, 10); // 1 + Random.pickNumberInRange(1, 10); // 10 + Random.pickNumberInRange(1, 10); // 4 + Random.pickNumberInRange(1, 10); // 5parseInt(string, radix); + ``` + +- `pickNumberInList(array)` + - 목록에 있는 숫자 중 하나를 반환한다 + - 형식은 다음과 같다 + + ``` javascript + Random.pickNumberInList([1, 3, 10]); // 1 + Random.pickNumberInList([1, 3, 10]); // 10 + Random.pickNumberInList([1, 3, 10]); // 3 + ``` + +- `pickUniqueNumbersInRange(startInclusive, endInclusive, count)` + - 숫자 범위 내에서 지정된 개수만큼 겹치지 않는 숫자를 반환한다 + - 형식은 다음과 같다 + + ``` javascript + Random.pickUniqueNumbersInRange(1, 10, 2); // [1, 2] + Random.pickUniqueNumbersInRange(1, 10, 5); // [1, 10, 7, 8, 5] + ``` + +- `shuffle(array)` + - 무작위로 섞인 새 목록을 반환한다 + - 형식은 다음과 같다 + + ``` javascript + Random.shuffle([1, 2, 3, 4, 5]); // [2, 4, 1, 3, 5] + ``` + +
+ +### T-4-2. 값이 4 이상일 경우 한칸 전진 테스트 + +- `jest.fn()` + - Jest에서 제공하는 모의 함수 + - 호출되었을 때, 어떻게 호출되었는지, 몇 번 호출되었는지, 어떤 인자로 호출되었는지 등의 정보를 추적한다 +- `jest.spyOn()` + - 객체의 메서드를 스파이하는 데 사용 + - 해당 메서드가 어떻게 호출되었는지 검증하면서, 원래의 구현을 유지하거나 모의 구현으로 대체할 수 있다 +- `mockReturnValue(value)` + - 모의 함수가 호출될 때 특정 값을 반환하도록 설정한다 +- `.mockImplementation(fn)` + - 원래의 함수 대신 제공된 fn 함수를 호출하도록 설정한다 +- `.mockImplementationOnce(fn)` + - 다음 호출에 대해서만 제공된 fn 함수를 호출하도록 설정한다 + +
+ +### T-4-3. 시도 횟수 만큼 경주 진행 테스트 + +- `기본적인 컨텍스트` + - 함수는 기본적으로 전역 컨텍스트에서 실행된다 + - 즉, 일반 함수에서 this를 참조하면 전역 객체 (브라우저에서는 window)를 가리키게 된다 + + ``` javascript + function checkThis() { + console.log(this); + } + + checkThis(); // Window + ``` + +- `메서드 호출에서의 컨텍스트` + - 객체의 메서드로 함수를 호출할 때, this는 해당 객체를 가리키게 된다 + + ``` javascript + const obj = { + checkThis: function() { + console.log(this); + } + } + + obj.checkThis(); + ``` + +- `컨텍스트 손실` + - 하지만, 메서드 내부에서 다른 함수나 메서드를 호출하게 되면 this의 바인딩이 손실될 수 있다 + + ``` javascript + const obj = { + name: "Seojin", + sayHello: function() { + function innerFunc() { + console.log(this.name); + } + innerFunc(); + } + } + + obj.sayHello(); + ``` + +- `명시적 바인딩` + - 이러한 문제를 해결하기 위해, bind, call, apply와 같은 메서드를 사용하여 함수의 this 값을 명시적으로 설정한다 + + ``` javascript + const obj = { + name: "Seojin", + sayHello: function() { + const innerFunc = function() { + console.log(this.name); + }.bind(this); + innerFunc(); + } + } + + obj.sayHello(); + ``` diff --git a/src/App.js b/src/App.js index c38b30d5b..6ca78b5bb 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,141 @@ +import { Console } from "@woowacourse/mission-utils"; +import { Random } from "@woowacourse/mission-utils" + class App { - async play() {} + + constructor() { + this.car = []; + } + + async play() { + const carName = await this.getCarName(); + const round = await this.getRoundNumber(); + + this.makeCar(carName); + this.printResultHeader(); + this.runRace(round); + + const winner = this.getWinner(); + this.printWinner(winner); + } + + async getCarName() { + const inputCar = await Console.readLineAsync("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)\n"); + return this.sanitizeCarName(inputCar); + } + + sanitizeCarName(inputCar) { + const sanitizedCar = inputCar.split(',').map(name => name.trim()).filter(name => name); + return this.#validateCarName(sanitizedCar); + } + + #validateCarName(sanitizedCar) { + this.#checkMinimumCar(sanitizedCar); + this.#checkCarLength(sanitizedCar); + this.#checkCarDuplicate(sanitizedCar); + return sanitizedCar; + } + + #checkMinimumCar(sanitizedCar) { + if (sanitizedCar.length < 2) { + throw new Error("[ERROR] 최소 두 대 이상의 자동차 이름을 입력해주세요."); + } + } + + #checkCarLength(sanitizedCar) { + if (sanitizedCar.some(name => name.length > 5)) { + throw new Error("[ERROR] 자동차 이름은 5자를 초과할 수 없습니다."); + } + } + + #checkCarDuplicate(sanitizedCar) { + if (new Set(sanitizedCar).size !== sanitizedCar.length) { + throw new Error("[ERROR] 중복되는 자동차 이름이 있습니다."); + } + } + + async getRoundNumber() { + const inputRound = await Console.readLineAsync("시도할 횟수는 몇 회인가요?\n"); + return this.sanitizeRoundNumber(inputRound); + } + + sanitizeRoundNumber(inputRound) { + const sanitizedRound = parseInt(inputRound.trim().replace(/\s+/g, ''), 10); + if (inputRound.includes('.') || !Number.isInteger(sanitizedRound)) { + throw new Error("[ERROR] 횟수는 자연수 형식이어야 합니다"); + } + return this.#validateRound(sanitizedRound); + } + + #validateRound(sanitizedRound) { + this.#checkMinimumRound(sanitizedRound); + this.#checkMaximumRound(sanitizedRound); + return sanitizedRound; + } + + #checkMinimumRound(sanitizedRound) { + if (sanitizedRound < 1) { + throw new Error("[ERROR] 최소 1 이상의 횟수를 입력해주세요."); + } + } + + #checkMaximumRound(sanitizedRound) { + if (sanitizedRound > 10) { + throw new Error("[ERROR] 최대 10 이하의 횟수를 입력해주세요."); + } + } + + makeCar(carName) { + const getRandom = this.getRandomNumber.bind(this); + this.car = carName.map(name =>({ + name, + distance: 0, + move() { + if (this.shouldMove()) { + this.distance += 1; + } + }, + shouldMove() { + const randomValue = getRandom(); + return randomValue >= 4; + } + })); + } + + getRandomNumber() { + const randomValue = Random.pickNumberInRange(0, 9); + return randomValue; + } + + runRace(totalRound) { + for (let i = 0; i < totalRound; i++) { + this.car.forEach(car => car.move()); + this.printRoundResult(); + Console.print("\n"); + } + } + + getWinner() { + const maxDistance = Math.max(...this.car.map(car => car.distance)); + return this.car.filter(car => car.distance === maxDistance).map(car => car.name); + } + + printResultHeader() { + Console.print("\n실행 결과"); + } + + printRoundResult() { + this.car.forEach(car => { + Console.print(`${car.name} : ${'-'.repeat(car.distance)}`); + }); + } + + printWinner(winner) { + Console.print(`최종 우승자 : ${winner.join(", ")}`); + } } +const app = new App(); +app.play(); + export default App;