diff --git a/README.md b/README.md index e078fd41..e05a8a25 100644 --- a/README.md +++ b/README.md @@ -1 +1,255 @@ -# javascript-racingcar-precourse +# 자동차 경주 게임 + +> SOLID 원칙과 MVC 패턴을 적용한 객체지향 자동차 경주 게임 + +## 목차 + +- [프로젝트 개요](#프로젝트-개요) +- [시스템 아키텍처](#시스템-아키텍처) +- [주요 기능](#주요-기능) +- [예외 처리](#예외-처리) +- [리팩토링 이력](#리팩토링-이력) +- [최종 결과](#최종-결과) + +## 프로젝트 개요 + +자동차 경주 게임은 객체지향 설계 원칙을 준수하여 구현된 콘솔 게임입니다. MVC 패턴을 적용하여 관심사를 분리하고, 단일 책임 원칙을 준수하여 유지보수성을 높였습니다. + +### 핵심 원칙 +- **SOLID 원칙**: 각 클래스는 단일 책임만 수행 +- **MVC 패턴**: Model-View-Controller 완전 분리 +- **Indent Depth**: 최대 2 (요구사항 준수) + +## 시스템 아키텍처 + +### 파일 구조 + +``` +src/ +├── constants/ # 상수 및 에러 메시지 +│ ├── constants.js +│ └── ErrorMessage.js +├── models/ # 도메인 모델 +│ └── Car.js +├── validators/ # 검증 로직 +│ └── Validator.js +├── views/ # UI 출력 +│ └── GameView.js +├── controllers/ # 게임 컨트롤러 +│ └── GameController.js +├── App.js # 메인 앱 +└── index.js # 진입점 +``` + +### 시스템 실행 흐름 + +![시스템 실행 흐름](docs/sequence-diagram.png) + +### 계층 구조 + +![시스템 계층 구조](docs/architecture-diagram.png) + +## 주요 기능 + +### 입력 및 검증 +- **자동차 이름**: 쉼표로 구분, 최대 5자, 공백 불가 +- **이동 횟수**: 양의 정수만 입력 가능 + +### 게임 로직 +- **전진 조건**: 랜덤값(0~9)이 4 이상일 때 전진 +- **실시간 결과**: 각 라운드마다 자동차 위치 출력 +- **우승자 판정**: 최대 전진 거리를 가진 자동차(들) 선정 + +### 게임 흐름 +1. 자동차 이름 입력 및 검증 +2. 이동 횟수 입력 및 검증 +3. 자동차 객체 생성 +4. 라운드별 게임 진행 및 결과 출력 +5. 최종 우승자 판정 및 출력 + +## 예외 처리 + +### 검증 규칙 + +| 분류 | 규칙 | 잘못된 예시 | +|------|------|------------| +| **자동차 이름** | 빈 문자열, 5자 초과, 공백 포함 불가 | `""`, `javaji`, `car 1` | +| **이동 횟수** | 빈 문자열, 음수, 소수, 공백 포함 불가 | `""`, `0`, `-5`, `3.5`, ` 5 ` | + +### 검증 로직 + +```javascript +// 자동차 이름 검증 (세분화된 에러 메시지) +Validator.validateCarNames(carNames); +// 체크 항목: +// - 빈 문자열 입력 +// - 5자 초과 +// - 공백 포함 (앞/뒤/중간) +// - 빈 이름 포함 (쉼표로 구분된 빈 값) + +// 이동 횟수 검증 (세분화된 에러 메시지) +Validator.validateMovementCount(movementCountInput); +// 체크 항목: +// - 빈 문자열 +// - 음수 +// - 0 +// - 소수점 +// - 공백 포함 +// - 특수문자 +``` + +## 리팩토링 이력 + +### 1. 코드 분리 (178줄 → 230줄) + +**문제점** +- 모든 로직이 `App.js`에 집중 +- 단일 책임 원칙 위배 +- 코드 재사용성 및 테스트 어려움 + +**해결방안** +- 8개 파일로 분리 +- App.js: 178줄 → 35줄 (80% 감소) +- 각 모듈의 책임 명확화 + +**결과** +```javascript +// Before: 178줄 +App.js (모든 로직 포함) + +// After: 35줄 +App.js (GameController만 호출) +GameController.js (게임 흐름 제어) +GameView.js (UI 출력) +Validator.js (검증 로직) +Car.js (자동차 모델) +``` + +### 2. 에러 메시지 세분화 및 중앙 관리 + +**문제점** +- 에러 메시지 여러 곳에 하드코딩 +- 일반적인 에러 메시지로 구체적인 문제 파악 어려움 + +**해결방안** +```javascript +// ErrorMessage.js +export class ErrorMessage { + // 자동차 이름 관련 (4가지) + static EMPTY_CAR_NAMES = "[ERROR] 자동차 이름을 입력해주세요."; + static INVALID_CAR_NAME_LENGTH = "[ERROR] 자동차 이름은 5자 이하만 가능합니다."; + static INVALID_CAR_NAME_SPACE = "[ERROR] 자동차 이름에 공백이 포함될 수 없습니다."; + static EMPTY_CAR_NAME_IN_LIST = "[ERROR] 빈 자동차 이름이 포함되어 있습니다."; + + // 이동 횟수 관련 (5가지) + static EMPTY_MOVEMENT_COUNT = "[ERROR] 시도할 횟수를 입력해주세요."; + static INVALID_MOVEMENT_COUNT_NEGATIVE = "[ERROR] 시도 횟수는 1 이상이어야 합니다."; + static INVALID_MOVEMENT_COUNT_ZERO = "[ERROR] 시도 횟수는 0보다 커야 합니다."; + static INVALID_MOVEMENT_COUNT_DECIMAL = "[ERROR] 시도 횟수는 정수여야 합니다."; + static INVALID_MOVEMENT_COUNT_SPACE = "[ERROR] 시도 횟수에 공백이 포함될 수 없습니다."; + static INVALID_NUMBER_FORMAT = "[ERROR] 숫자만 입력해주세요."; +} +``` + +**장점** +- 메시지 변경 시 한 파일만 수정 +- IDE 자동완성 지원 +- 구체적인 에러 원인 파악 용이 +- 사용자에게 명확한 피드백 제공 + +### 3. MissionUtils API 수정 + +**에러** +``` +Error: arguments must be 1 + at MissionUtils.Console.readLineAsync +``` + +**원인** +- `readLineAsync()` 인자 중복 전달 + +**해결** +```javascript +// Before +const input = await GameView.readLine(message); +GameView.print(message); // 중복 출력 + +// After +static async readLine(query) { + MissionUtils.Console.print(query); + return await MissionUtils.Console.readLineAsync("> "); +} +``` + +### 4. 랜덤값 범위 오류 수정 + +**원인** +- 요구사항: 0~9 랜덤값, 4 이상일 때 전진 +- 잘못된 구현: 4~9만 선택 + +**해결** +```javascript +// Before (잘못됨) +const randomNumber = Random.pickNumberInRange(4, 9); + +// After (올바름) +const randomNumber = Random.pickNumberInRange(0, 9); +return randomNumber >= 4; +``` + +### 5. 검증 로직 통합 및 변수명 개선 + +**문제점** +- 검증 로직 여러 곳에 산재 +- 중복 코드 존재 +- 변수명이 애매함 (`name`, `input`, `number`) + +**해결방안** +- `Validator` 클래스로 통합 +- 변수명 명확화: + - `name` → `carName` + - `trimmedName` → `trimmedCarName` + - `input` → `movementCountInput` + - `trimmedInput` → `trimmedMovementCount` + - `number` → `movementCount` +- JSDoc 주석 추가 + +**장점** +- 변수 의미가 한눈에 파악 가능 +- 검증 로직이 명확하게 이해됨 +- 코드 가독성 향상 + +## 최종 결과 + +### 코드 메트릭 + +| 항목 | 값 | +|------|-----| +| 총 라인 수 | 297줄 (8개 파일) | +| App.js 라인 수 | 14줄 (최소화) | +| 평균 파일 크기 | ~37줄 | +| Indent Depth | 최대 2 | +| 테스트 통과율 | 16/16 (100%) | + +### 디자인 패턴 + +- **Repository Pattern**: `ErrorMessage.js`, `constants.js` - 데이터 중앙 관리 +- **Strategy Pattern**: `Validator.js` - 검증 전략 캡슐화 +- **MVC Pattern**: Model(Car) - View(GameView) - Controller(GameController) +- **Facade Pattern**: `App.js` - 복잡한 시스템 단순화 + +### 테스트 결과 + +**기능 테스트 (4개)** +- 단일 라운드에서 단독 우승자 결정 +- 여러 라운드 경주 진행 +- 공동 우승자 결정 +- 3대의 자동차가 다른 거리 이동 + +**예외 처리 테스트 (12개)** +- 자동차 이름 검증 (5개): 5자 초과, 빈 문자열, 공백 포함, 앞뒤 공백, 빈 이름 포함 +- 이동 횟수 검증 (7개): 빈 문자열, 숫자 아님, 음수, 0, 소수, 공백 포함, 특수문자 + +**BDD 패턴 적용** +- Given-When-Then 주석으로 테스트 구조 명확화 +- 모든 테스트 케이스에 일관된 구조 적용 diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js index 0260e7e8..1fce7309 100644 --- a/__tests__/ApplicationTest.js +++ b/__tests__/ApplicationTest.js @@ -1,6 +1,11 @@ import App from "../src/App.js"; import { MissionUtils } from "@woowacourse/mission-utils"; +import { Car } from "../src/models/Car.js"; +/** + * 사용자 입력 모킹 함수 + * @param {string[]} inputs - 입력할 값들의 배열 + */ const mockQuestions = (inputs) => { MissionUtils.Console.readLineAsync = jest.fn(); @@ -10,6 +15,10 @@ const mockQuestions = (inputs) => { }); }; +/** + * 랜덤값 모킹 함수 + * @param {number[]} numbers - 반환할 랜덤값들의 배열 + */ const mockRandoms = (numbers) => { MissionUtils.Random.pickNumberInRange = jest.fn(); @@ -18,6 +27,10 @@ const mockRandoms = (numbers) => { }, MissionUtils.Random.pickNumberInRange); }; +/** + * 출력 로그를 추적하는 스파이 객체 생성 + * @returns {jest.SpyInstance} 콘솔 출력 스파이 + */ const getLogSpy = () => { const logSpy = jest.spyOn(MissionUtils.Console, "print"); logSpy.mockClear(); @@ -25,36 +38,338 @@ const getLogSpy = () => { }; describe("자동차 경주", () => { - test("기능 테스트", async () => { - // given - const MOVING_FORWARD = 4; - const STOP = 3; - const inputs = ["pobi,woni", "1"]; - const logs = ["pobi : -", "woni : ", "최종 우승자 : pobi"]; - const logSpy = getLogSpy(); + describe("기능 테스트", () => { + test("단일 라운드에서 단독 우승자 결정", async () => { + // given: 전진하는 자동차와 멈추는 자동차 설정 + const MOVING_FORWARD = 4; + const STOP = 3; + const inputs = ["pobi,woni", "1"]; + const logs = ["pobi : -", "woni : ", "최종 우승자 : pobi"]; + const logSpy = getLogSpy(); - mockQuestions(inputs); - mockRandoms([MOVING_FORWARD, STOP]); + mockQuestions(inputs); + mockRandoms([MOVING_FORWARD, STOP]); - // when - const app = new App(); - await app.run(); + // when: 게임 실행 + const app = new App(); + await app.run(); - // then - logs.forEach((log) => { - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + // then: 예상된 출력이 정확히 호출되었는지 확인 + logs.forEach((log) => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }); + }); + + test("여러 라운드 경주 진행", async () => { + // given: 3라운드 진행 설정 + const MOVING_FORWARD = 4; + const STOP = 3; + const inputs = ["pobi,jun", "3"]; + const logs = [ + "pobi : -", // 라운드 1: pobi 전진 + "jun : ", // 라운드 1: jun 멈춤 + "pobi : -", // 라운드 2: pobi 전진 (누적 2) + "jun : ", // 라운드 2: jun 멈춤 (누적 0) + "pobi : -", // 라운드 3: pobi 전진 (누적 3) + "jun : ", // 라운드 3: jun 멈춤 (누적 0) + "최종 우승자 : pobi" + ]; + const logSpy = getLogSpy(); + + mockQuestions(inputs); + // pobi는 항상 전진, jun은 항상 멈춤 + mockRandoms([MOVING_FORWARD, STOP, MOVING_FORWARD, STOP, MOVING_FORWARD, STOP]); + + // when: 게임 실행 + const app = new App(); + await app.run(); + + // then: 각 라운드별 결과와 최종 우승자 확인 + logs.forEach((log) => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }); + }); + + test("공동 우승자 결정", async () => { + // given: 두 자동차가 같은 거리만큼 전진 + const MOVING_FORWARD = 4; + const inputs = ["pobi,jun", "2"]; + const logs = [ + "pobi : -", + "jun : -", + "pobi : --", + "jun : --", + "최종 우승자 : pobi, jun" + ]; + const logSpy = getLogSpy(); + + mockQuestions(inputs); + // 두 자동차 모두 항상 전진 + mockRandoms([MOVING_FORWARD, MOVING_FORWARD, MOVING_FORWARD, MOVING_FORWARD]); + + // when: 게임 실행 + const app = new App(); + await app.run(); + + // then: 공동 우승자가 쉼표로 구분되어 출력되는지 확인 + logs.forEach((log) => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }); + }); + + test("3대의 자동차가 다른 거리 이동", async () => { + // given: 3대의 자동차 경주 + const MOVING_FORWARD = 4; + const STOP = 3; + const inputs = ["pobi,woni,jun", "3"]; + const logs = [ + // 라운드 1: pobi 전진, woni 멈춤, jun 전진 → pobi:1, woni:0, jun:1 + "pobi : -", + "woni : ", + "jun : -", + // 라운드 2: pobi 멈춤, woni 전진, jun 전진 → pobi:1, woni:1, jun:2 + "pobi : -", + "woni : -", + "jun : --", + // 라운드 3: pobi 전진, woni 전진, jun 전진 → pobi:2, woni:2, jun:3 + "pobi : --", + "woni : --", + "jun : ---", + "최종 우승자 : jun" + ]; + const logSpy = getLogSpy(); + + mockQuestions(inputs); + // 라운드 1: pobi 전진(4), woni 멈춤(3), jun 전진(4) + // 라운드 2: pobi 멈춤(3), woni 전진(4), jun 전진(4) + // 라운드 3: pobi 전진(4), woni 전진(4), jun 전진(4) + mockRandoms([ + MOVING_FORWARD, STOP, MOVING_FORWARD, + STOP, MOVING_FORWARD, MOVING_FORWARD, + MOVING_FORWARD, MOVING_FORWARD, MOVING_FORWARD + ]); + + // when: 게임 실행 + const app = new App(); + await app.run(); + + // then: 각 자동차의 위치와 공동 우승자 확인 + logs.forEach((log) => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }); + }); + + test("중복된 자동차 이름이 있는 경우 구분하여 처리", async () => { + // given: 중복된 이름이 있는 자동차들 + Car.resetIdCounter(); // ID 카운터 리셋 + const MOVING_FORWARD = 4; + const STOP = 3; + const inputs = ["pobi,pobi,jun", "2"]; + const logs = [ + "pobi#1 : -", // 첫 번째 pobi 전진 + "pobi#2 : ", // 두 번째 pobi 멈춤 + "jun : -", // jun 전진 + "pobi#1 : -", // 첫 번째 pobi 전진 (누적 1) + "pobi#2 : ", // 두 번째 pobi 멈춤 (누적 0) + "jun : -", // jun 전진 (누적 1) + "최종 우승자 : pobi#1, jun" // 공동 우승 + ]; + const logSpy = getLogSpy(); + + mockQuestions(inputs); + // 첫 번째 pobi는 전진, 두 번째 pobi는 멈춤, jun은 전진 + mockRandoms([MOVING_FORWARD, STOP, MOVING_FORWARD, MOVING_FORWARD, STOP, MOVING_FORWARD]); + + // when: 게임 실행 + const app = new App(); + await app.run(); + + // then: 중복 이름이 ID로 구분되어 출력되는지 확인 + logs.forEach((log) => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }); + }); + + test("중복된 자동차 이름이 있는 경우 구분하여 처리", async () => { + // given: 중복된 이름이 있는 자동차들 + Car.resetIdCounter(); // ID 카운터 리셋 + const MOVING_FORWARD = 4; + const STOP = 3; + const inputs = ["pobi,pobi,jun", "2"]; + const logs = [ + "pobi#1 : -", // 첫 번째 pobi 전진 + "pobi#2 : ", // 두 번째 pobi 멈춤 + "jun : -", // jun 전진 + "pobi#1 : -", // 첫 번째 pobi 전진 (누적 1) + "pobi#2 : ", // 두 번째 pobi 멈춤 (누적 0) + "jun : -", // jun 전진 (누적 1) + "최종 우승자 : pobi#1, jun" // 공동 우승 + ]; + const logSpy = getLogSpy(); + + mockQuestions(inputs); + // 첫 번째 pobi는 전진, 두 번째 pobi는 멈춤, jun은 전진 + mockRandoms([MOVING_FORWARD, STOP, MOVING_FORWARD, MOVING_FORWARD, STOP, MOVING_FORWARD]); + + // when: 게임 실행 + const app = new App(); + await app.run(); + + // then: 중복 이름이 ID로 구분되어 출력되는지 확인 + logs.forEach((log) => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }); }); }); - test("예외 테스트", async () => { - // given - const inputs = ["pobi,javaji"]; - mockQuestions(inputs); + describe("예외 처리 테스트", () => { + describe("자동차 이름 검증", () => { + test("자동차 이름이 5자 초과인 경우", async () => { + // given: 6자 이름 입력 + const inputs = ["pobi,javaji"]; + mockQuestions(inputs); - // when - const app = new App(); + // when: 앱 실행 + const app = new App(); + + // then: 에러 발생 확인 + await expect(app.run()).rejects.toThrow("[ERROR] 자동차 이름은 5자 이하만 가능합니다."); + }); - // then - await expect(app.run()).rejects.toThrow("[ERROR]"); + test("빈 문자열 입력", async () => { + // given: 빈 문자열 입력 + const inputs = [""]; + mockQuestions(inputs); + + // when: 앱 실행 + const app = new App(); + + // then: 에러 발생 확인 + await expect(app.run()).rejects.toThrow("[ERROR] 자동차 이름을 입력해주세요."); + }); + + test("자동차 이름에 공백이 포함된 경우", async () => { + // given: 공백이 포함된 이름 입력 + const inputs = ["po bi"]; + mockQuestions(inputs); + + // when: 앱 실행 + const app = new App(); + + // then: 에러 발생 확인 + await expect(app.run()).rejects.toThrow("[ERROR] 자동차 이름에 공백이 포함될 수 없습니다."); + }); + + test("자동차 이름 앞뒤에 공백이 있는 경우", async () => { + // given: 앞뒤 공백이 포함된 이름 입력 + const inputs = [" pobi "]; + mockQuestions(inputs); + + // when: 앱 실행 + const app = new App(); + + // then: 에러 발생 확인 + await expect(app.run()).rejects.toThrow("[ERROR] 자동차 이름에 공백이 포함될 수 없습니다."); + }); + + test("빈 이름이 포함된 경우", async () => { + // given: 쉼표로 구분된 빈 값 입력 + const inputs = ["pobi,,jun"]; + mockQuestions(inputs); + + // when: 앱 실행 + const app = new App(); + + // then: 에러 발생 확인 + await expect(app.run()).rejects.toThrow("[ERROR] 빈 자동차 이름이 포함되어 있습니다."); + }); + + }); + + describe("이동 횟수 검증", () => { + test("빈 문자열 입력", async () => { + // given: 자동차 이름은 정상, 이동 횟수는 빈 문자열 + const inputs = ["pobi,jun", ""]; + mockQuestions(inputs); + + // when: 앱 실행 + const app = new App(); + + // then: 에러 발생 확인 + await expect(app.run()).rejects.toThrow("[ERROR] 시도할 횟수를 입력해주세요."); + }); + + test("숫자가 아닌 값 입력", async () => { + // given: 숫자가 아닌 문자 입력 + const inputs = ["pobi,jun", "abc"]; + mockQuestions(inputs); + + // when: 앱 실행 + const app = new App(); + + // then: 에러 발생 확인 + await expect(app.run()).rejects.toThrow("[ERROR] 숫자만 입력해주세요."); + }); + + test("음수 입력", async () => { + // given: 음수 입력 + const inputs = ["pobi,jun", "-5"]; + mockQuestions(inputs); + + // when: 앱 실행 + const app = new App(); + + // then: 에러 발생 확인 + await expect(app.run()).rejects.toThrow("[ERROR] 시도 횟수는 1 이상이어야 합니다."); + }); + + test("0 입력", async () => { + // given: 0 입력 + const inputs = ["pobi,jun", "0"]; + mockQuestions(inputs); + + // when: 앱 실행 + const app = new App(); + + // then: 에러 발생 확인 + await expect(app.run()).rejects.toThrow("[ERROR] 시도 횟수는 0보다 커야 합니다."); + }); + + test("소수점 입력", async () => { + // given: 소수 입력 + const inputs = ["pobi,jun", "3.5"]; + mockQuestions(inputs); + + // when: 앱 실행 + const app = new App(); + + // then: 에러 발생 확인 + await expect(app.run()).rejects.toThrow("[ERROR] 시도 횟수는 정수여야 합니다."); + }); + + test("이동 횟수에 공백 포함", async () => { + // given: 공백이 포함된 숫자 입력 + const inputs = ["pobi,jun", " 5 "]; + mockQuestions(inputs); + + // when: 앱 실행 + const app = new App(); + + // then: 에러 발생 확인 + await expect(app.run()).rejects.toThrow("[ERROR] 시도 횟수에 공백이 포함될 수 없습니다."); + }); + + test("특수문자 입력", async () => { + // given: 특수문자 입력 + const inputs = ["pobi,jun", "@"]; + mockQuestions(inputs); + + // when: 앱 실행 + const app = new App(); + + // then: 에러 발생 확인 + await expect(app.run()).rejects.toThrow("[ERROR] 숫자만 입력해주세요."); + }); + }); }); }); diff --git a/docs/architecture-diagram.mmd b/docs/architecture-diagram.mmd new file mode 100644 index 00000000..820f5763 --- /dev/null +++ b/docs/architecture-diagram.mmd @@ -0,0 +1,34 @@ +graph TB + subgraph "메인 계층" + App["App
메인 앱"] + Index["index.js
진입점"] + end + + subgraph "컨트롤러 계층" + GC["GameController
게임 흐름 제어"] + end + + subgraph "도메인 계층" + Car["Car
자동차 모델"] + end + + subgraph "뷰 계층" + GV["GameView
UI 출력"] + end + + subgraph "유틸리티 계층" + Validator["Validator
검증 로직"] + Constants["constants.js
상수 정의"] + ErrorMsg["ErrorMessage
에러 메시지"] + end + + Index --> App + App --> GC + GC --> GV + GC --> Validator + GC --> Car + GV --> Validator + Validator --> Constants + Validator --> ErrorMsg + Car --> Constants + diff --git a/docs/architecture-diagram.png b/docs/architecture-diagram.png new file mode 100644 index 00000000..b8b72a9c Binary files /dev/null and b/docs/architecture-diagram.png differ diff --git a/docs/sequence-diagram.mmd b/docs/sequence-diagram.mmd new file mode 100644 index 00000000..e69de29b diff --git a/docs/sequence-diagram.png b/docs/sequence-diagram.png new file mode 100644 index 00000000..07ea2168 Binary files /dev/null and b/docs/sequence-diagram.png differ diff --git a/src/App.js b/src/App.js index 091aa0a5..85c49154 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,14 @@ +import { GameController } from "./controllers/GameController.js"; + +/** + * 메인 애플리케이션 + * 애플리케이션 진입점 역할 + */ class App { - async run() {} + async run() { + const controller = new GameController(); + await controller.run(); + } } export default App; diff --git a/src/constants/ErrorMessage.js b/src/constants/ErrorMessage.js new file mode 100644 index 00000000..7644dce5 --- /dev/null +++ b/src/constants/ErrorMessage.js @@ -0,0 +1,19 @@ +// 에러 메시지 관리 +export class ErrorMessage { + // 자동차 이름 관련 에러 + static EMPTY_CAR_NAMES = "[ERROR] 자동차 이름을 입력해주세요."; + static INVALID_CAR_NAME_LENGTH = "[ERROR] 자동차 이름은 5자 이하만 가능합니다."; + static INVALID_CAR_NAME_SPACE = "[ERROR] 자동차 이름에 공백이 포함될 수 없습니다."; + static EMPTY_CAR_NAME_IN_LIST = "[ERROR] 빈 자동차 이름이 포함되어 있습니다."; + static DUPLICATE_CAR_NAME = "[ERROR] 중복된 자동차 이름이 있습니다."; + + // 이동 횟수 관련 에러 + static EMPTY_MOVEMENT_COUNT = "[ERROR] 시도할 횟수를 입력해주세요."; + static INVALID_NUMBER_FORMAT = "[ERROR] 숫자만 입력해주세요."; + static INVALID_MOVEMENT_COUNT_NEGATIVE = "[ERROR] 시도 횟수는 1 이상이어야 합니다."; + static INVALID_MOVEMENT_COUNT_ZERO = "[ERROR] 시도 횟수는 0보다 커야 합니다."; + static INVALID_MOVEMENT_COUNT_DECIMAL = "[ERROR] 시도 횟수는 정수여야 합니다."; + static INVALID_MOVEMENT_COUNT_SPACE = "[ERROR] 시도 횟수에 공백이 포함될 수 없습니다."; +} + + diff --git a/src/constants/constants.js b/src/constants/constants.js new file mode 100644 index 00000000..19cfcfb1 --- /dev/null +++ b/src/constants/constants.js @@ -0,0 +1,10 @@ +// 상수 정의 +export const MIN_ADVANCE_NUMBER = 4; +export const MAX_ADVANCE_NUMBER = 9; +export const MIN_MOVEMENT_COUNT = 1; +export const MAX_CAR_NAME_LENGTH = 5; +export const SEPARATOR = ","; + + + + diff --git a/src/controllers/GameController.js b/src/controllers/GameController.js new file mode 100644 index 00000000..5d8ed332 --- /dev/null +++ b/src/controllers/GameController.js @@ -0,0 +1,89 @@ +import { Car } from "../models/Car.js"; +import { GameView } from "../views/GameView.js"; +import { Validator } from "../validators/Validator.js"; +import { SEPARATOR } from "../constants/constants.js"; + +/** + * 게임 컨트롤러 (MVC - Controller) + * 전체 게임 흐름을 관리하고 조율하는 역할 + */ +export class GameController { + async getCarNamesInput() { + const message = "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"; + const input = await GameView.readLine(message); + return input; + } + + async getMovementCountInput() { + const message = "시도할 횟수는 몇 회인가요?"; + const input = await GameView.readLine(message); + return input; + } + + parseCarNames(input) { + const carNames = input.split(SEPARATOR); + return carNames; + } + + createCars(carNames) { + const cars = carNames.map((name) => new Car(name)); + return cars; + } + + playRound(cars) { + for (const car of cars) { + car.move(); + } + GameView.printRoundResult(cars); + } + + playGame(cars, movementCount) { + GameView.printGameHeader(); + + for (let i = 0; i < movementCount; i++) { + this.playRound(cars); + } + } + + /** + * 우승자 찾기 + * 최대 position을 가진 모든 자동차 반환 (공동 우승 가능) + */ + findWinners(cars) { + let maxPosition = 0; + + for (const car of cars) { + if (car.position > maxPosition) { + maxPosition = car.position; + } + } + + const winners = cars.filter((car) => car.position === maxPosition); + return winners; + } + + /** + * 전체 게임 실행 흐름 관리 + */ + async run() { + // 자동차 이름 입력 및 검증 + const carNamesInput = await this.getCarNamesInput(); + const carNames = this.parseCarNames(carNamesInput); + Validator.validateCarNames(carNames); + + // 이동 횟수 입력 및 검증 + const movementCountInput = await this.getMovementCountInput(); + const movementCount = Validator.validateMovementCount(movementCountInput); + + // 자동차 생성 + const cars = this.createCars(carNames); + + // 경주 진행 + this.playGame(cars, movementCount); + + // 우승자 판정 및 출력 + const winners = this.findWinners(cars); + GameView.printWinners(winners, cars); + } +} + diff --git a/src/models/Car.js b/src/models/Car.js new file mode 100644 index 00000000..b672455f --- /dev/null +++ b/src/models/Car.js @@ -0,0 +1,59 @@ +import { MissionUtils } from "@woowacourse/mission-utils"; +import { MIN_ADVANCE_NUMBER, MAX_ADVANCE_NUMBER } from "../constants/constants.js"; + +/** + * 자동차 모델 (MVC - Model) + */ +export class Car { + static idCounter = 0; + + constructor(name) { + this.name = name; + this.position = 0; + this.id = ++Car.idCounter; // 고유 ID 할당 + } + + /** + * ID 카운터 리셋 (테스트용) + */ + static resetIdCounter() { + Car.idCounter = 0; + } + + /** + * 전진 여부 판단 + * 0~9 랜덤값 중 4 이상이면 전진 + */ + shouldAdvance() { + const randomNumber = MissionUtils.Random.pickNumberInRange(0, MAX_ADVANCE_NUMBER); + return randomNumber >= MIN_ADVANCE_NUMBER; + } + + move() { + if (this.shouldAdvance()) { + this.position += 1; + } + } + + /** + * 고유 식별자 반환 (이름이 중복되어도 구분 가능) + * @returns {string} 고유 식별자 + */ + getUniqueIdentifier() { + return `${this.name}#${this.id}`; + } + + /** + * 디스플레이용 이름 반환 (중복 시 ID 포함) + * @param {Car[]} allCars - 모든 자동차 배열 + * @returns {string} 디스플레이용 이름 + */ + getDisplayName(allCars = []) { + const duplicateNames = allCars.filter(car => car.name === this.name); + if (duplicateNames.length > 1) { + return this.getUniqueIdentifier(); + } + return this.name; + } +} + diff --git a/src/validators/Validator.js b/src/validators/Validator.js new file mode 100644 index 00000000..0122e1d8 --- /dev/null +++ b/src/validators/Validator.js @@ -0,0 +1,96 @@ +import { ErrorMessage } from "../constants/ErrorMessage.js"; +import { MAX_CAR_NAME_LENGTH, MIN_MOVEMENT_COUNT } from "../constants/constants.js"; + +/** + * 검증 로직 담당 + */ +export class Validator { + /** + * 자동차 이름 배열 전체를 검증 + * @param {string[]} carNames - 검증할 자동차 이름 배열 + */ + static validateCarNames(carNames) { + if (carNames.length === 0) { + throw new Error(ErrorMessage.EMPTY_CAR_NAMES); + } + + // 모든 이름이 빈 문자열인지 확인 + const hasNonEmptyName = carNames.some(name => name.trim().length > 0); + if (!hasNonEmptyName) { + throw new Error(ErrorMessage.EMPTY_CAR_NAMES); + } + + // 중복 이름은 허용하되, 나중에 구분할 수 있도록 처리 + + for (const carName of carNames) { + const trimmedCarName = carName.trim(); + + // 빈 이름이 포함된 경우 (쉼표로 구분된 빈 값) + if (trimmedCarName.length === 0 && carName.length > 0) { + throw new Error(ErrorMessage.EMPTY_CAR_NAME_IN_LIST); + } + + // 빈 문자열 자체 + if (carName.length === 0) { + throw new Error(ErrorMessage.EMPTY_CAR_NAME_IN_LIST); + } + + // 앞뒤 공백이 있는 경우 + if (trimmedCarName !== carName) { + throw new Error(ErrorMessage.INVALID_CAR_NAME_SPACE); + } + + // 이름 중간에 공백이 있는 경우 + if (carName.includes(' ')) { + throw new Error(ErrorMessage.INVALID_CAR_NAME_SPACE); + } + + // 이름 길이가 5자를 초과하는 경우 + if (carName.length > MAX_CAR_NAME_LENGTH) { + throw new Error(ErrorMessage.INVALID_CAR_NAME_LENGTH); + } + } + } + + /** + * 이동 횟수 입력을 검증하고 숫자로 변환하여 반환 + * @param {string} movementCountInput - 검증할 이동 횟수 입력값 + * @returns {number} 검증된 이동 횟수 + */ + static validateMovementCount(movementCountInput) { + const trimmedMovementCount = movementCountInput.trim(); + + if (trimmedMovementCount.length === 0) { + throw new Error(ErrorMessage.EMPTY_MOVEMENT_COUNT); + } + + if (trimmedMovementCount !== movementCountInput) { + throw new Error(ErrorMessage.INVALID_MOVEMENT_COUNT_SPACE); + } + + if (isNaN(Number(trimmedMovementCount))) { + throw new Error(ErrorMessage.INVALID_NUMBER_FORMAT); + } + + const movementCount = Number(trimmedMovementCount); + + if (!Number.isInteger(movementCount)) { + throw new Error(ErrorMessage.INVALID_MOVEMENT_COUNT_DECIMAL); + } + + if (movementCount === 0) { + throw new Error(ErrorMessage.INVALID_MOVEMENT_COUNT_ZERO); + } + + if (movementCount < 0) { + throw new Error(ErrorMessage.INVALID_MOVEMENT_COUNT_NEGATIVE); + } + + if (movementCount < MIN_MOVEMENT_COUNT) { + throw new Error(ErrorMessage.INVALID_MOVEMENT_COUNT_NEGATIVE); + } + + return movementCount; + } +} + diff --git a/src/views/GameView.js b/src/views/GameView.js new file mode 100644 index 00000000..b6733706 --- /dev/null +++ b/src/views/GameView.js @@ -0,0 +1,43 @@ +import { MissionUtils } from "@woowacourse/mission-utils"; + +/** + * 출력 관련 (MVC - View) + * 모든 메서드가 static으로 View는 상태를 가지지 않음 + */ +export class GameView { + static print(message) { + MissionUtils.Console.print(message); + } + + static async readLine(query) { + MissionUtils.Console.print(query); + const input = await MissionUtils.Console.readLineAsync("> "); + return input; + } + + static printCarStatus(car, allCars = []) { + const dashes = "-".repeat(car.position); + const displayName = car.getDisplayName(allCars); + const status = `${displayName} : ${dashes}`; + GameView.print(status); + } + + static printRoundResult(cars) { + for (const car of cars) { + GameView.printCarStatus(car, cars); + } + GameView.print(""); + } + + static printGameHeader() { + GameView.print(""); + GameView.print("실행 결과"); + } + + static printWinners(winners, allCars = []) { + const winnerNames = winners.map((winner) => winner.getDisplayName(allCars)); + const winnerNamesString = winnerNames.join(", "); + GameView.print(`최종 우승자 : ${winnerNamesString}`); + } +} +