diff --git a/README.md b/README.md index 5c31c2f3d..8de42de83 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,43 @@ # 미션 - 점심 메뉴 추천 -## 🔍 진행 방식 +## 기능 구현 목록 +1. 시작 메시지 출력 + - “점심 메뉴 추천을 시작합니다.” +2. 코치 이름 입력 + - “코치의 이름을 입력해 주세요. (, 로 구분)” + - 예외 사항 + 1. 형식 오류 시 예외 발생 + - “입력 형식이 올바르지 않습니다.” + 2. 코치가 2명 미만 5명 초과면 예외 발생 + - “코치는 최소 2명 이상 입력해야 합니다.” + - “코치는 최대 5명 이하로 입력해야 합니다.” + 3. 코치의 이름이 2자 미만 4자 초과면 예외 발생 + - “코치의 이름은 2자 이상 4자 이하여야 합니다.” + 4. 코치의 이름에 중복이 있으면 예외 발생 + - "중복된 이름이 있습니다." +3. 코치 별 못 먹는 메뉴 입력 + - “%s(이)가 못 먹는 메뉴를 입력해 주세요.” + - 예외 사항 + 1. 형식 오류 시 예외 발생 + - “입력 형식이 올바르지 않습니다.” + 2. 메뉴 개수가 2개 초과인 경우 예외 발생 + - “못 먹는 메뉴의 개수는 2개 이하여야 합니다.” + 3. 메뉴에 중복이 있으면 예외 발생 + - "중복된 메뉴가 있습니다." +4. 요일별(월~금 순서대로) 카테고리를 뽑는다. + - 만약 이미 2번 뽑힌 카테고리가 나온다면 다시 뽑는다. + 1. 코치별 메뉴를 뽑는다. + - 만약 한 코치에게 이미 해당 메뉴를 추천했다면 다시 뽑는다. +5. 메뉴 추천 목록 출력 +- ```java + 메뉴 추천 결과입니다. + [ 구분 | 월요일 | 화요일 | 수요일 | 목요일 | 금요일 ] + [ 카테고리 | 한식 | 한식 | 일식 | 중식 | 아시안 ] + [ 토미 | 쌈밥 | 김치찌개 | 미소시루 | 짜장면 | 팟타이 ] + [ 제임스 | 된장찌개 | 비빔밥 | 가츠동 | 토마토 달걀볶음 | 파인애플 볶음밥 ] + [ 포코 | 된장찌개 | 불고기 | 하이라이스 | 탕수육 | 나시고렝 ] + + 추천을 완료했습니다. + ``` -- 미션은 **기능 요구 사항, 프로그래밍 요구 사항, 과제 진행 요구 사항** 세 가지로 구성되어 있다. -- 세 개의 요구 사항을 만족하기 위해 노력한다. 특히 기능을 구현하기 전에 기능 목록을 만들고, 기능 단위로 커밋 하는 방식으로 진행한다. -- 기능 요구 사항에 기재되지 않은 내용은 스스로 판단하여 구현한다. -## 📮 미션 제출 방법 - -- 미션 구현을 완료한 후 GitHub을 통해 제출해야 한다. - - GitHub을 활용한 제출 - 방법은 [프리코스 과제 제출](https://github.com/woowacourse/woowacourse-docs/tree/master/precourse) 문서를 참고해 - 제출한다. -- GitHub에 미션을 제출한 후 [우아한테크코스 지원](https://apply.techcourse.co.kr) 사이트에 접속하여 프리코스 과제를 제출한다. - - 자세한 방법은 [제출 가이드](https://github.com/woowacourse/woowacourse-docs/tree/master/precourse#제출-가이드) - 참고 - - **Pull Request만 보내고 지원 플랫폼에서 과제를 제출하지 않으면 최종 제출하지 않은 것으로 처리되니 주의한다.** - -## 🚨 과제 제출 전 체크 리스트 - 0점 방지 - -- 기능 구현을 모두 정상적으로 했더라도 **요구 사항에 명시된 출력값 형식을 지키지 않을 경우 0점으로 처리**한다. -- 기능 구현을 완료한 뒤 아래 가이드에 따라 테스트를 실행했을 때 모든 테스트가 성공하는지 확인한다. -- **테스트가 실패할 경우 0점으로 처리**되므로, 반드시 확인 후 제출한다. - -### 테스트 실행 가이드 - -- 터미널에서 `java -version`을 실행하여 Java 버전이 11인지 확인한다. 또는 Eclipse 또는 IntelliJ IDEA와 같은 IDE에서 Java 11로 - 실행되는지 확인한다. -- 터미널에서 Mac 또는 Linux 사용자의 경우 `./gradlew clean test` 명령을 실행하고, - Windows 사용자의 경우 `gradlew.bat clean test` 명령을 실행할 때 모든 테스트가 아래와 같이 통과하는지 확인한다. - -``` -BUILD SUCCESSFUL in 0s -``` - ---- - -## 🚀 기능 요구 사항 - -한 주의 점심 메뉴를 추천해 주는 서비스다. - -- 코치들은 월, 화, 수, 목, 금요일에 점심 식사를 같이 한다. -- 메뉴를 추천하는 과정은 아래와 같이 이뤄진다. - 1. 월요일에 추천할 카테고리를 무작위로 정한다. - 2. 각 코치가 월요일에 먹을 메뉴를 추천한다. - 3. 화, 수, 목, 금요일에 대해 i, ii 과정을 반복한다. -- 코치의 이름은 최소 2글자, 최대 4글자이다. -- 코치는 최소 2명, 최대 5명까지 식사를 함께 한다. -- 각 코치는 최소 0개, 최대 2개의 못 먹는 메뉴가 있다. (`,` 로 구분해서 입력한다.) - - 먹지 못하는 메뉴가 없으면 빈 값을 입력한다. - - 추천을 못하는 경우는 발생하지 않으니 고려하지 않아도 된다. -- 한 주에 같은 카테고리는 최대 2회까지만 고를 수 있다. -- 각 코치에게 한 주에 중복되지 않는 메뉴를 추천해야 한다. - - 예시) - - 구구: 비빔밥, 김치찌개, 쌈밥, 규동, 우동 → 한식을 3회 먹으므로 불가능 - - 토미: 비빔밥, 비빔밥, 규동, 우동, 볶음면 → 한 코치가 같은 메뉴를 먹으므로 불가능 - - 제임스: 비빔밥, 김치찌개, 스시, 가츠동, 짜장면 → 매일 다른 메뉴를 먹으므로 가능 - - 포코: 비빔밥, 김치찌개, 스시, 가츠동, 짜장면 → 제임스와 메뉴가 같지만, 포코는 매번 다른 메뉴를 먹으므로 가능 -- 메뉴 추천을 완료하면 프로그램이 종료된다. -- 사용자가 잘못된 값을 입력할 경우 `IllegalArgumentException`를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 - 받는다. - - `Exception`이 아닌 `IllegalArgumentException`, `IllegalStateException` 등과 같은 명확한 유형을 처리한다. - -### 입출력 요구 사항 - -#### 입력 - -- 메뉴 추천을 받을 코치의 이름을 입력받는다. 올바른 값이 아니면 예외 처리한다. - -``` -토미,제임스,포코 -``` - -- 각 코치가 못 먹는 메뉴를 입력받는다. - -``` -우동,스시 -``` - -#### 출력 - -- 서비스 시작 문구 - -``` -점심 메뉴 추천을 시작합니다. -``` - -- 서비스 종료 문구 - -``` -메뉴 추천 결과입니다. -[ 구분 | 월요일 | 화요일 | 수요일 | 목요일 | 금요일 ] -[ 카테고리 | 한식 | 한식 | 일식 | 중식 | 아시안 ] -[ 토미 | 쌈밥 | 김치찌개 | 미소시루 | 짜장면 | 팟타이 ] -[ 제임스 | 된장찌개 | 비빔밥 | 가츠동 | 토마토 달걀볶음 | 파인애플 볶음밥 ] -[ 포코 | 된장찌개 | 불고기 | 하이라이스 | 탕수육 | 나시고렝 ] - -추천을 완료했습니다. -``` - -- 예외 상황 시 에러 문구를 출력해야 한다. 단, 에러 문구는 "[ERROR]"로 시작해야 한다. - -``` -[ERROR] 코치는 최소 2명 이상 입력해야 합니다. -``` - -#### 실행 결과 예시 - -``` -점심 메뉴 추천을 시작합니다. - -코치의 이름을 입력해 주세요. (, 로 구분) -토미,제임스,포코 - -토미(이)가 못 먹는 메뉴를 입력해 주세요. -우동,스시 - -제임스(이)가 못 먹는 메뉴를 입력해 주세요. -뇨끼,월남쌈 - -포코(이)가 못 먹는 메뉴를 입력해 주세요. -마파두부,고추잡채 - -메뉴 추천 결과입니다. -[ 구분 | 월요일 | 화요일 | 수요일 | 목요일 | 금요일 ] -[ 카테고리 | 한식 | 한식 | 일식 | 중식 | 아시안 ] -[ 토미 | 쌈밥 | 김치찌개 | 미소시루 | 짜장면 | 팟타이 ] -[ 제임스 | 된장찌개 | 비빔밥 | 가츠동 | 토마토 달걀볶음 | 파인애플 볶음밥 ] -[ 포코 | 된장찌개 | 불고기 | 하이라이스 | 탕수육 | 나시고렝 ] - -추천을 완료했습니다. -``` - ---- - -## 🎯 프로그래밍 요구 사항 - -- JDK 11 버전에서 실행 가능해야 한다. **JDK 11에서 정상적으로 동작하지 않을 경우 0점 처리한다.** -- 프로그램 실행의 시작점은 `Application`의 `main()`이다. -- `build.gradle` 파일을 변경할 수 없고, 외부 라이브러리를 사용하지 않는다. -- [Java 코드 컨벤션](https://github.com/woowacourse/woowacourse-docs/tree/master/styleguide/java) 가이드를 - 준수하며 프로그래밍한다. -- 프로그램 종료 시 `System.exit()`를 호출하지 않는다. -- 프로그램 구현이 완료되면 `ApplicationTest`의 모든 테스트가 성공해야 한다. **테스트가 실패할 경우 0점 처리한다.** -- 프로그래밍 요구 사항에서 달리 명시하지 않는 한 파일, 패키지 이름을 수정하거나 이동하지 않는다. -- indent(인덴트, 들여쓰기) depth를 3이 넘지 않도록 구현한다. 2까지만 허용한다. - - 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다. - - 힌트: indent(인덴트, 들여쓰기) depth를 줄이는 좋은 방법은 함수(또는 메서드)를 분리하면 된다. -- 3항 연산자를 쓰지 않는다. -- 함수(또는 메서드)의 길이가 15라인을 넘어가지 않도록 구현한다. - - 함수(또는 메서드)가 한 가지 일만 잘하도록 구현한다. -- else 예약어를 쓰지 않는다. - - 힌트: if 조건절에서 값을 return 하는 방식으로 구현하면 else를 사용하지 않아도 된다. - - else를 쓰지 말라고 하니 switch/case로 구현하는 경우가 있는데 switch/case도 허용하지 않는다. - -### 카테고리와 메뉴 요구 사항 - -- 메뉴 추천 서비스에서 추천할 수 있는 카테고리와 각 카테고리의 메뉴는 아래와 같다. - -``` -일식: 규동, 우동, 미소시루, 스시, 가츠동, 오니기리, 하이라이스, 라멘, 오코노미야끼 -한식: 김밥, 김치찌개, 쌈밥, 된장찌개, 비빔밥, 칼국수, 불고기, 떡볶이, 제육볶음 -중식: 깐풍기, 볶음면, 동파육, 짜장면, 짬뽕, 마파두부, 탕수육, 토마토 달걀볶음, 고추잡채 -아시안: 팟타이, 카오 팟, 나시고렝, 파인애플 볶음밥, 쌀국수, 똠얌꿍, 반미, 월남쌈, 분짜 -양식: 라자냐, 그라탱, 뇨끼, 끼슈, 프렌치 토스트, 바게트, 스파게티, 피자, 파니니 -``` - -#### 카테고리 - -- 추천할 - 카테고리는 [`camp.nextstep.edu.missionutils`](https://github.com/woowacourse-projects/mission-utils)에서 - 제공하는 `Randoms.pickNumberInRange()`에서 생성해 준 값을 이용하여 정해야 한다. - -```java -// 예시 코드. 사용하는 자료 구조에 따라 난수를 적절하게 가공해도 된다. -String category = categories.get(Randoms.pickNumberInRange(1, 5)); -``` - -- 임의로 카테고리의 순서 또는 데이터를 변경하면 안 된다. - - `Randoms.pickNumberInRange()`의 결과가 **1이면 일식, 2면 한식, 3이면 중식, 4면 아시안, 5면 양식**을 추천해야 한다. -- 추천할 수 없는 카테고리인 경우 다시 `Randoms.pickNumberInRange()`를 통해 임의의 값을 생성해서 추천할 카테고리를 정해야 한다. - -#### 메뉴 - -- 추천할 메뉴는 정해진 카테고리에 있는 - 메뉴를 [`camp.nextstep.edu.missionutils`](https://github.com/woowacourse-projects/mission-utils)에서 - 제공하는 `Randoms.shuffle()`을 통해 임의의 순서로 섞은 후, 첫 번째 값을 사용해야 한다. - - 카테고리에 포함되는 메뉴 목록을 `List` 형태로 준비한다. - -```java -String menu = Randoms.shuffle(menus).get(0); -``` - -- 임의로 메뉴의 순서 또는 데이터를 변경하면 안 된다. - - `Randoms.shuffle()` 메서드의 인자로 전달되는 메뉴 데이터는, 최초에 제공한 목록을 그대로 전달해야 한다. - - 코치에게 추천할 메뉴를 정할 때 이미 추천한 메뉴, 먹지 못하는 메뉴도 포함된 리스트를 전달해야 한다. -- 추천할 수 없는 메뉴인 경우 다시 섞은 후 첫 번째 값을 사용해야 한다. - ---- - -## ✏️ 과제 진행 요구 사항 - -- 미션은 [java-menu](https://github.com/woowacourse-precourse/java-menu) 저장소를 Fork & Clone해 시작한다. -- **기능을 구현하기 전 `docs/README.md`에 구현할 기능 목록을 정리**해 추가한다. -- **Git의 커밋 단위는 앞 단계에서 `docs/README.md`에 정리한 기능 목록 단위**로 추가한다. - - [커밋 메시지 컨벤션](https://gist.github.com/stephenparish/9941e89d80e2bc58a153) 가이드를 참고해 커밋 메시지를 작성한다. -- 과제 진행 및 제출 방법은 [프리코스 과제 제출](https://github.com/woowacourse/woowacourse-docs/tree/master/precourse) - 문서를 참고한다. - - 소감은 간소하게 입력해도 된다. 예를 들어, "."만 입력해도 좋다. diff --git a/src/main/java/menu/Application.java b/src/main/java/menu/Application.java index 6340b6f33..60efbbef7 100644 --- a/src/main/java/menu/Application.java +++ b/src/main/java/menu/Application.java @@ -1,7 +1,13 @@ package menu; +import menu.controller.MenuController; +import menu.service.MenuService; + public class Application { + public static void main(String[] args) { - // TODO: 프로그램 구현 + MenuService menuService = new MenuService(); + MenuController menuController = new MenuController(menuService); + menuController.run(); } } diff --git a/src/main/java/menu/constant/Category.java b/src/main/java/menu/constant/Category.java new file mode 100644 index 000000000..f04a321c8 --- /dev/null +++ b/src/main/java/menu/constant/Category.java @@ -0,0 +1,45 @@ +package menu.constant; + +import java.util.Arrays; +import java.util.List; + +public enum Category { + JAPANESE(1, "일식", List.of("규동", "우동", "미소시루", "스시", "가츠동", "오니기리", "하이라이스", "라멘", "오코노미야끼")), + KOREAN(2, "한식", List.of("김밥", "김치찌개", "쌈밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이", "제육볶음")), + CHINESE(3, "중식", List.of("깐풍기", "볶음면", "동파육", "짜장면", "짬뽕", "마파두부", "탕수육", "토마토 달걀볶음", "고추잡채")), + ASIAN(4, "아시안", List.of("팟타이", "카오 팟", "나시고렝", "파인애플 볶음밥", "쌀국수", "똠얌꿍", "반미", "월남쌈", "분짜")), + WESTERN(5, "양식", List.of("라자냐", "그라탱", "뇨끼", "끼슈", "프렌치 토스트", "바게트", "스파게티", "피자", "파니니")), + ; + + private final int index; + private final String name; + private final List menus; + + Category(int index, String name, List menus) { + this.index = index; + this.name = name; + this.menus = menus; + } + + public static Category fromIndex(int index) { + return Arrays.stream(values()) + .filter(category -> category.index == index) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException(ErrorMessage.NO_EXIST_CATEGORY_ERROR.getErrorMessage())); + } + + public static Category fromName(String name) { + return Arrays.stream(values()) + .filter(category -> category.name.equals(name)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException(ErrorMessage.NO_EXIST_CATEGORY_ERROR.getErrorMessage())); + } + + public String getName() { + return name; + } + + public List getMenus() { + return menus; + } +} diff --git a/src/main/java/menu/constant/ErrorMessage.java b/src/main/java/menu/constant/ErrorMessage.java new file mode 100644 index 000000000..eaf707656 --- /dev/null +++ b/src/main/java/menu/constant/ErrorMessage.java @@ -0,0 +1,25 @@ +package menu.constant; + +public enum ErrorMessage { + + FORMAT_ERROR("입력 형식이 올바르지 않습니다."), + COACH_COUNT_MIN_ERROR("코치는 최소 2명 이상 입력해야 합니다."), + COACH_COUNT_MAX_ERROR("코치는 5명 이하로 입력해야 합니다."), + COACH_NAME_LENGTH_ERROR("코치의 이름은 2자 이상 4자 이하여야 합니다."), + REJECTED_MENU_COUNT_ERROR("못 먹는 메뉴의 개수는 2개 이하여야 합니다"), + NO_EXIST_MENU_ERROR("없는 메뉴입니다."), + UNIQUE_ERROR("중복된 값이 있습니다."), + NO_EXIST_CATEGORY_ERROR("없는 카테고리 입니다."), + ; + + private static final String ERROR_MESSAGE_PREFIX = "[ERROR] "; + private final String errorMessage; + + ErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getErrorMessage(Object... args) { + return ERROR_MESSAGE_PREFIX + String.format(errorMessage, args); + } +} diff --git a/src/main/java/menu/controller/MenuController.java b/src/main/java/menu/controller/MenuController.java new file mode 100644 index 000000000..cf3746c10 --- /dev/null +++ b/src/main/java/menu/controller/MenuController.java @@ -0,0 +1,71 @@ +package menu.controller; + +import java.util.List; +import java.util.function.Supplier; +import menu.domain.Coach; +import menu.domain.Coaches; +import menu.domain.Recommendation; +import menu.service.MenuService; +import menu.util.InputParser; +import menu.view.InputView; +import menu.view.OutputView; + +public class MenuController { + + private final MenuService menuService; + + public MenuController(MenuService menuService) { + this.menuService = menuService; + } + + public void run() { + OutputView.printStart(); + + Coaches coaches = registerCoachNames(); + for (Coach coach : coaches.getCoaches()) { + registerRejectedMenus(coach.getName()); + } + + Recommendation result = menuService.getResult(); + OutputView.printResult(result); + } + + private void registerRejectedMenus(String coachName) { + retryOnError(() -> { + String readRejectedMenus = InputView.readRejectedMenus(coachName); + List rejectedMenus = InputParser.parseRejectedMenus(readRejectedMenus); + + menuService.registerRejectedMenus(coachName, rejectedMenus); + }); + } + + private Coaches registerCoachNames() { + return retryOnError(() -> { + String readCoachNames = InputView.readCoachNames(); + List coachNames = InputParser.parseCoachNames(readCoachNames); + + return menuService.registerCoachNames(coachNames); + }); + } + + private T retryOnError(Supplier supplier) { + while (true) { + try { + return supplier.get(); + } catch (IllegalArgumentException e) { + OutputView.printErrorMessage(e); + } + } + } + + private void retryOnError(Runnable runnable) { + while (true) { + try { + runnable.run(); + return; + } catch (IllegalArgumentException e) { + OutputView.printErrorMessage(e); + } + } + } +} diff --git a/src/main/java/menu/domain/Coach.java b/src/main/java/menu/domain/Coach.java new file mode 100644 index 000000000..7a30a5dfc --- /dev/null +++ b/src/main/java/menu/domain/Coach.java @@ -0,0 +1,68 @@ +package menu.domain; + +import java.util.ArrayList; +import java.util.List; +import menu.constant.ErrorMessage; +import menu.constant.Category; + +public class Coach { + + public static final int COACH_NAME_LENGTH_MIN = 2; + public static final int COACH_NAME_LENGTH_MAX = 4; + + private final String name; + private List rejectedMenus; + private final List recommendedMenus; + + private Coach(String name) { + this.name = name; + this.rejectedMenus = new ArrayList<>(); + this.recommendedMenus = new ArrayList<>(); + } + + public static Coach from(String name) { + validate(name); + + return new Coach(name); + } + + private static void validate(String name) { + if (name.length() < COACH_NAME_LENGTH_MIN || name.length() > COACH_NAME_LENGTH_MAX) { + throw new IllegalArgumentException(ErrorMessage.COACH_NAME_LENGTH_ERROR.getErrorMessage()); + } + } + + public String getName() { + return name; + } + + public List getRecommendedMenus() { + return recommendedMenus; + } + + public void addRejectedMenus(List rejectedMenus) { + for (String rejectedMenu : rejectedMenus) { + validateExistence(rejectedMenu); + } + this.rejectedMenus = new ArrayList<>(rejectedMenus); + } + + private void validateExistence(String rejectedMenu) { + for (Category menu : Category.values()) { + List menus = menu.getMenus(); + if (menus.contains(rejectedMenu)) { + return; + } + } + + throw new IllegalArgumentException(ErrorMessage.NO_EXIST_MENU_ERROR.getErrorMessage()); + } + + public boolean isPossible(String menu) { + return !recommendedMenus.contains(menu) && !rejectedMenus.contains(menu); + } + + public void addRecommendedMenu(String menu) { + this.recommendedMenus.add(menu); + } +} diff --git a/src/main/java/menu/domain/Coaches.java b/src/main/java/menu/domain/Coaches.java new file mode 100644 index 000000000..7058c55fb --- /dev/null +++ b/src/main/java/menu/domain/Coaches.java @@ -0,0 +1,35 @@ +package menu.domain; + +import java.util.ArrayList; +import java.util.List; + +public class Coaches { + + private final List coaches; + + public Coaches() { + this.coaches = new ArrayList<>(); + } + + public static Coaches newInstance() { + return new Coaches(); + } + + public void addCoach(String coachName) { + Coach coach = Coach.from(coachName); + coaches.add(coach); + } + + public Coach getCoach(String coachName) { + for (Coach coach : coaches) { + if (coach.getName().equals(coachName)) { + return coach; + } + } + throw new IllegalArgumentException(); + } + + public List getCoaches() { + return coaches; + } +} diff --git a/src/main/java/menu/domain/Recommendation.java b/src/main/java/menu/domain/Recommendation.java new file mode 100644 index 000000000..003473f09 --- /dev/null +++ b/src/main/java/menu/domain/Recommendation.java @@ -0,0 +1,42 @@ +package menu.domain; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class Recommendation { + + public static final int RECOMMENDED_MENUS_SIZE = 5; + + private final Coaches coaches; + private final List categories; + + private Recommendation(Coaches coaches) { + this.categories = new ArrayList<>(); + this.coaches = coaches; + } + + public static Recommendation from(Coaches coaches) { + return new Recommendation(coaches); + } + + public boolean isDone() { + return categories.size() == RECOMMENDED_MENUS_SIZE; + } + + public boolean isPossible(String category) { + return Collections.frequency(categories, category) < 2; + } + + public void addCategory(String category) { + categories.add(category); + } + + public Coaches getCoaches() { + return coaches; + } + + public List getCategories() { + return categories; + } +} diff --git a/src/main/java/menu/generator/RandomCategoryGenerator.java b/src/main/java/menu/generator/RandomCategoryGenerator.java new file mode 100644 index 000000000..96411a548 --- /dev/null +++ b/src/main/java/menu/generator/RandomCategoryGenerator.java @@ -0,0 +1,12 @@ +package menu.generator; + +import camp.nextstep.edu.missionutils.Randoms; +import menu.constant.Category; + +public final class RandomCategoryGenerator { + + public static String generateCategory() { + int index = Randoms.pickNumberInRange(1, 5); + return Category.fromIndex(index).getName(); + } +} diff --git a/src/main/java/menu/generator/RandomMenuGenerator.java b/src/main/java/menu/generator/RandomMenuGenerator.java new file mode 100644 index 000000000..b3dd7e324 --- /dev/null +++ b/src/main/java/menu/generator/RandomMenuGenerator.java @@ -0,0 +1,11 @@ +package menu.generator; + +import camp.nextstep.edu.missionutils.Randoms; +import java.util.List; + +public final class RandomMenuGenerator { + + public static String generateMenu(List menus) { + return Randoms.shuffle(menus).get(0); + } +} diff --git a/src/main/java/menu/service/MenuService.java b/src/main/java/menu/service/MenuService.java new file mode 100644 index 000000000..21a4ab209 --- /dev/null +++ b/src/main/java/menu/service/MenuService.java @@ -0,0 +1,61 @@ +package menu.service; + +import java.util.List; +import menu.constant.Category; +import menu.domain.Coach; +import menu.domain.Coaches; +import menu.domain.Recommendation; +import menu.generator.RandomCategoryGenerator; +import menu.generator.RandomMenuGenerator; + +public class MenuService { + + private Coaches coaches; + + public Coaches registerCoachNames(List coachNames) { + coaches = Coaches.newInstance(); + + for (String coachName : coachNames) { + coaches.addCoach(coachName); + } + + return coaches; + } + + public void registerRejectedMenus(String coachName, List rejectedMenus) { + Coach coach = coaches.getCoach(coachName); + coach.addRejectedMenus(rejectedMenus); + } + + public Recommendation getResult() { + Recommendation recommendation = Recommendation.from(coaches); + + while (!recommendation.isDone()) { // 추천 끝났으면(추천 카테고리 항목이 5개이면) + String category = RandomCategoryGenerator.generateCategory(); + if (recommendation.isPossible(category)) { // 2번 미만으로 추천 되었으면 + recommendation.addCategory(category); // 추천 카테고리 추가 + addRecommendedMenu(category); // 추천 메뉴 추가 + } + } + + return recommendation; + } + + private void addRecommendedMenu(String category) { + for (Coach coach : coaches.getCoaches()) { // 코치별로 메뉴 추천 + addRecommendedMenuEachCoach(category, coach); + } + } + + private void addRecommendedMenuEachCoach(String category, Coach coach) { + while (true) { + List candidateMenus = Category.fromName(category).getMenus(); // 카테고리에 맞는 메뉴 후보 가져오기 + String menu = RandomMenuGenerator.generateMenu(candidateMenus); // 후보 중 메뉴 하나 랜덤 선택 + + if (coach.isPossible(menu)) { // 이미 추천했거나 못먹는 음식이라면 + coach.addRecommendedMenu(menu); // 메뉴 추가 + break; + } + } + } +} diff --git a/src/main/java/menu/util/InputParser.java b/src/main/java/menu/util/InputParser.java new file mode 100644 index 000000000..6baa0336a --- /dev/null +++ b/src/main/java/menu/util/InputParser.java @@ -0,0 +1,47 @@ +package menu.util; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public final class InputParser { + + private static final String DELIMITER = ","; + + private InputParser() { + } + + private static List parseToElements(String rawInput) { + rawInput = rawInput.strip(); + + return Stream.of(rawInput.split(DELIMITER)) + .map(String::strip) + .collect(Collectors.toList()); + } + + public static List parseCoachNames(String rawInput) { + Validator.validateCoachNameFormat(rawInput.strip()); + + List names = parseToElements(rawInput); + + Validator.validateUnique(names); + Validator.validateCoachNames(names); + + return names; + } + + public static List parseRejectedMenus(String rawInput) { + if (rawInput.isBlank()) { + return List.of(); + } + + Validator.validateRejectedMenuFormat(rawInput.strip()); + + List menus = parseToElements(rawInput); + + Validator.validateUnique(menus); + Validator.validateRejectedMenus(menus); + + return menus; + } +} diff --git a/src/main/java/menu/util/Validator.java b/src/main/java/menu/util/Validator.java new file mode 100644 index 000000000..548d7751c --- /dev/null +++ b/src/main/java/menu/util/Validator.java @@ -0,0 +1,49 @@ +package menu.util; + +import java.util.HashSet; +import java.util.List; +import menu.constant.ErrorMessage; + +public final class Validator { + + private static final String CSV_FORMAT = "^ *[가-힣a-zA-Z]+ *(, *[가-힣a-zA-Z]+ *)*$"; + public static final int COACH_COUNT_MIN = 2; + public static final int COACH_COUNT_MAX = 5; + public static final int REJECTED_MENU_COUNT_MAX = 2; + + private Validator() {} + + public static void validateCoachNameFormat(String input) { + if (!input.matches(CSV_FORMAT)) { + throw new IllegalArgumentException(ErrorMessage.FORMAT_ERROR.getErrorMessage()); + } + } + + public static void validateCoachNames(List names) { + if (names.size() < COACH_COUNT_MIN) { + throw new IllegalArgumentException(ErrorMessage.COACH_COUNT_MIN_ERROR.getErrorMessage()); + } + + if (names.size() > COACH_COUNT_MAX) { + throw new IllegalArgumentException(ErrorMessage.COACH_COUNT_MAX_ERROR.getErrorMessage()); + } + } + + public static void validateRejectedMenuFormat(String input) { + if (!input.matches(CSV_FORMAT)) { + throw new IllegalArgumentException(ErrorMessage.FORMAT_ERROR.getErrorMessage()); + } + } + + public static void validateRejectedMenus(List menus) { + if (menus.size() > REJECTED_MENU_COUNT_MAX) { + throw new IllegalArgumentException(ErrorMessage.REJECTED_MENU_COUNT_ERROR.getErrorMessage()); + } + } + + public static void validateUnique(List inputs) { + if (inputs.size() != new HashSet<>(inputs).size()) { + throw new IllegalArgumentException(ErrorMessage.UNIQUE_ERROR.getErrorMessage()); + } + } +} diff --git a/src/main/java/menu/view/InputView.java b/src/main/java/menu/view/InputView.java new file mode 100644 index 000000000..45c526262 --- /dev/null +++ b/src/main/java/menu/view/InputView.java @@ -0,0 +1,19 @@ +package menu.view; + +import camp.nextstep.edu.missionutils.Console; + +public class InputView { + + private static final String COACH_NAME_REQUEST = "코치의 이름을 입력해 주세요. (, 로 구분)"; + private static final String REJECTED_MENUS_REQUEST = "%s(이)가 못 먹는 메뉴를 입력해 주세요.\n"; + + public static String readCoachNames() { + System.out.println(COACH_NAME_REQUEST); + return Console.readLine(); + } + + public static String readRejectedMenus(String coachName) { + System.out.printf(REJECTED_MENUS_REQUEST, coachName); + return Console.readLine(); + } +} diff --git a/src/main/java/menu/view/OutputView.java b/src/main/java/menu/view/OutputView.java new file mode 100644 index 000000000..a485690eb --- /dev/null +++ b/src/main/java/menu/view/OutputView.java @@ -0,0 +1,37 @@ +package menu.view; + +import java.util.List; +import menu.domain.Coach; +import menu.domain.Coaches; +import menu.domain.Recommendation; + +public class OutputView { + + private static final String START_MESSAGE = "점심 메뉴 추천을 시작합니다.\n"; + private static final String RESULT = "메뉴 추천 결과입니다.\n[ 구분 | 월요일 | 화요일 | 수요일 | 목요일 | 금요일 ]"; + private static final String CATEGORY_RESULT = "[ 카테고리 | %s ]\n"; + private static final String MENU_RESULT = "[ %s | %s ]\n"; + private static final String FINISH_MESSAGE = "추천을 완료했습니다."; + + public static void printStart() { + System.out.println(START_MESSAGE); + } + + public static void printErrorMessage(IllegalArgumentException e) { + System.out.println(e.getMessage()); + } + + public static void printResult(Recommendation result) { + System.out.println(RESULT); + + List categories = result.getCategories(); + System.out.printf(CATEGORY_RESULT, String.join(" | ", categories.toArray(new String[0]))); + + Coaches coaches = result.getCoaches(); + for (Coach coach : coaches.getCoaches()) { + System.out.printf(MENU_RESULT, coach.getName(), String.join(" | ", coach.getRecommendedMenus().toArray(new String[0]))); + } + + System.out.println(FINISH_MESSAGE); + } +} diff --git a/src/test/java/menu/ApplicationTest.java b/src/test/java/menu/ApplicationTest.java index a757e7813..8e5c9b550 100644 --- a/src/test/java/menu/ApplicationTest.java +++ b/src/test/java/menu/ApplicationTest.java @@ -1,5 +1,6 @@ package menu; +import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.mockito.ArgumentMatchers.anyInt; @@ -11,10 +12,13 @@ import java.time.Duration; import java.util.Arrays; import java.util.List; +import menu.constant.ErrorMessage; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.MockedStatic; public class ApplicationTest extends NsTest { @@ -71,6 +75,225 @@ class AllFeatureTest { ); }); } + + @Test + void 못먹는_메뉴_반환시_다시_메뉴_추천_케이스() { + assertTimeoutPreemptively(RANDOM_TEST_TIMEOUT, () -> { + final Executable executable = () -> { + runException("구구,제임스", "김밥", "라자냐"); + + assertThat(output()).contains( + "점심 메뉴 추천을 시작합니다.", + "코치의 이름을 입력해 주세요. (, 로 구분)", + "구구(이)가 못 먹는 메뉴를 입력해 주세요.", + "제임스(이)가 못 먹는 메뉴를 입력해 주세요.", + "메뉴 추천 결과입니다.", + "[ 구분 | 월요일 | 화요일 | 수요일 | 목요일 | 금요일 ]", + "[ 카테고리 | 한식 | 양식 | 일식 | 중식 | 아시안 ]", + "[ 구구 | 김치찌개 | 스파게티 | 규동 | 짜장면 | 카오 팟 ]", + "[ 제임스 | 제육볶음 | 그라탱 | 가츠동 | 짬뽕 | 파인애플 볶음밥 ]", + "추천을 완료했습니다." + ); + }; + + assertRandomTest(executable, + Mocking.ofRandomNumberInRange(2, 5, 1, 3, 4), // 숫자는 카테고리 번호를 나타낸다. + Mocking.ofShuffle( + // 월요일 + List.of("김밥", "김치찌개", "쌈밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이", "제육볶음"), // 구구 + List.of("김치찌개", "김밥", "쌈밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이", "제육볶음"), // 구구 + List.of("제육볶음", "김밥", "김치찌개", "쌈밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이"), // 제임스 + + // 화요일 + List.of("스파게티", "라자냐", "그라탱", "뇨끼", "끼슈", "프렌치 토스트", "바게트", "피자", "파니니"), // 구구 + List.of("라자냐", "그라탱", "뇨끼", "끼슈", "프렌치 토스트", "바게트", "스파게티", "피자", "파니니"), // 제임스 + List.of("그라탱", "라자냐", "뇨끼", "끼슈", "프렌치 토스트", "바게트", "스파게티", "피자", "파니니"), // 제임스 + + // 수요일 + List.of("규동", "우동", "미소시루", "스시", "가츠동", "오니기리", "하이라이스", "라멘", "오코노미야끼"), // 구구 + List.of("가츠동", "규동", "우동", "미소시루", "스시", "오니기리", "하이라이스", "라멘", "오코노미야끼"), // 제임스 + + // 목요일 + List.of("짜장면", "깐풍기", "볶음면", "동파육", "짬뽕", "마파두부", "탕수육", "토마토 달걀볶음", "고추잡채"), // 구구 + List.of("짬뽕", "깐풍기", "볶음면", "동파육", "짜장면", "마파두부", "탕수육", "토마토 달걀볶음", "고추잡채"), // 제임스 + + // 금요일 + List.of("카오 팟", "팟타이", "나시고렝", "파인애플 볶음밥", "쌀국수", "똠얌꿍", "반미", "월남쌈", "분짜"), // 구구 + List.of("파인애플 볶음밥", "팟타이", "카오 팟", "나시고렝", "쌀국수", "똠얌꿍", "반미", "월남쌈", "분짜") // 제임스 + ) + ); + }); + } + + @Test + void 이미_추천한_메뉴_반환시_다시_메뉴_추천_케이스() { + assertTimeoutPreemptively(RANDOM_TEST_TIMEOUT, () -> { + final Executable executable = () -> { + runException("구구,제임스", "김밥", "떡볶이"); + + assertThat(output()).contains( + "점심 메뉴 추천을 시작합니다.", + "코치의 이름을 입력해 주세요. (, 로 구분)", + "구구(이)가 못 먹는 메뉴를 입력해 주세요.", + "제임스(이)가 못 먹는 메뉴를 입력해 주세요.", + "메뉴 추천 결과입니다.", + "[ 구분 | 월요일 | 화요일 | 수요일 | 목요일 | 금요일 ]", + "[ 카테고리 | 한식 | 한식 | 일식 | 중식 | 아시안 ]", + "[ 구구 | 김치찌개 | 쌈밥 | 규동 | 짜장면 | 카오 팟 ]", + "[ 제임스 | 제육볶음 | 김치찌개 | 가츠동 | 짬뽕 | 파인애플 볶음밥 ]", + "추천을 완료했습니다." + ); + }; + + assertRandomTest(executable, + Mocking.ofRandomNumberInRange(2, 2, 1, 3, 4), // 숫자는 카테고리 번호를 나타낸다. + Mocking.ofShuffle( + // 월요일 + List.of("김치찌개", "김밥", "쌈밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이", "제육볶음"), // 구구 + List.of("제육볶음", "김밥", "김치찌개", "쌈밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이"), // 제임스 + + // 화요일 + List.of("김치찌개", "김밥", "쌈밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이", "제육볶음"), // 구구 + List.of("쌈밥", "김치찌개", "김밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이", "제육볶음"), // 구구 + List.of("김치찌개", "제육볶음", "김밥", "쌈밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이"), // 제임스 + + // 수요일 + List.of("규동", "우동", "미소시루", "스시", "가츠동", "오니기리", "하이라이스", "라멘", "오코노미야끼"), // 구구 + List.of("가츠동", "규동", "우동", "미소시루", "스시", "오니기리", "하이라이스", "라멘", "오코노미야끼"), // 제임스 + + // 목요일 + List.of("짜장면", "깐풍기", "볶음면", "동파육", "짬뽕", "마파두부", "탕수육", "토마토 달걀볶음", "고추잡채"), // 구구 + List.of("짬뽕", "깐풍기", "볶음면", "동파육", "짜장면", "마파두부", "탕수육", "토마토 달걀볶음", "고추잡채"), // 제임스 + + // 금요일 + List.of("카오 팟", "팟타이", "나시고렝", "파인애플 볶음밥", "쌀국수", "똠얌꿍", "반미", "월남쌈", "분짜"), // 구구 + List.of("파인애플 볶음밥", "팟타이", "카오 팟", "나시고렝", "쌀국수", "똠얌꿍", "반미", "월남쌈", "분짜") // 제임스 + ) + ); + }); + } + + @Test + void 이미_2번_추천한_카테고리_반환시_다시_추천_케이스() { + assertTimeoutPreemptively(RANDOM_TEST_TIMEOUT, () -> { + final Executable executable = () -> { + runException("구구,제임스", "김밥", "떡볶이"); + + assertThat(output()).contains( + "점심 메뉴 추천을 시작합니다.", + "코치의 이름을 입력해 주세요. (, 로 구분)", + "구구(이)가 못 먹는 메뉴를 입력해 주세요.", + "제임스(이)가 못 먹는 메뉴를 입력해 주세요.", + "메뉴 추천 결과입니다.", + "[ 구분 | 월요일 | 화요일 | 수요일 | 목요일 | 금요일 ]", + "[ 카테고리 | 한식 | 한식 | 일식 | 중식 | 아시안 ]", + "[ 구구 | 김치찌개 | 쌈밥 | 규동 | 짜장면 | 카오 팟 ]", + "[ 제임스 | 제육볶음 | 김치찌개 | 가츠동 | 짬뽕 | 파인애플 볶음밥 ]", + "추천을 완료했습니다." + ); + }; + + assertRandomTest(executable, + Mocking.ofRandomNumberInRange(2, 2, 2, 1, 3, 4), // 숫자는 카테고리 번호를 나타낸다. + Mocking.ofShuffle( + // 월요일 + List.of("김치찌개", "김밥", "쌈밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이", "제육볶음"), // 구구 + List.of("제육볶음", "김밥", "김치찌개", "쌈밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이"), // 제임스 + + // 화요일 + List.of("김치찌개", "김밥", "쌈밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이", "제육볶음"), // 구구 + List.of("쌈밥", "김치찌개", "김밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이", "제육볶음"), // 구구 + List.of("김치찌개", "제육볶음", "김밥", "쌈밥", "된장찌개", "비빔밥", "칼국수", "불고기", "떡볶이"), // 제임스 + + // 수요일 + List.of("규동", "우동", "미소시루", "스시", "가츠동", "오니기리", "하이라이스", "라멘", "오코노미야끼"), // 구구 + List.of("가츠동", "규동", "우동", "미소시루", "스시", "오니기리", "하이라이스", "라멘", "오코노미야끼"), // 제임스 + + // 목요일 + List.of("짜장면", "깐풍기", "볶음면", "동파육", "짬뽕", "마파두부", "탕수육", "토마토 달걀볶음", "고추잡채"), // 구구 + List.of("짬뽕", "깐풍기", "볶음면", "동파육", "짜장면", "마파두부", "탕수육", "토마토 달걀볶음", "고추잡채"), // 제임스 + + // 금요일 + List.of("카오 팟", "팟타이", "나시고렝", "파인애플 볶음밥", "쌀국수", "똠얌꿍", "반미", "월남쌈", "분짜"), // 구구 + List.of("파인애플 볶음밥", "팟타이", "카오 팟", "나시고렝", "쌀국수", "똠얌꿍", "반미", "월남쌈", "분짜") // 제임스 + ) + ); + }); + } + + @Test + void 코치_이름_형식_오류() { + assertSimpleTest(() -> { + runException("제이미-제이콥"); + assertThat(output()).contains(ErrorMessage.FORMAT_ERROR.getErrorMessage()); + }); + } + + @Test + void 코치_최소_인원_오류() { + assertSimpleTest(() -> { + runException("제이미"); + assertThat(output()).contains(ErrorMessage.COACH_COUNT_MIN_ERROR.getErrorMessage()); + }); + } + + @Test + void 코치_최대_인원_오류() { + assertSimpleTest(() -> { + runException("제이미,제이콥,포비,제임스,메시,사비"); + assertThat(output()).contains(ErrorMessage.COACH_COUNT_MAX_ERROR.getErrorMessage()); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"이니에스타,메시", "홉,메시"}) + void 코치_이름_길이_오류(String input) { + assertSimpleTest(() -> { + runException(input); + assertThat(output()).contains(ErrorMessage.COACH_NAME_LENGTH_ERROR.getErrorMessage()); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"메시,메시"}) + void 코치_이름_중복_오류(String input) { + assertSimpleTest(() -> { + runException(input); + assertThat(output()).contains(ErrorMessage.UNIQUE_ERROR.getErrorMessage()); + }); + } + + @Test + void 코치별_못먹는_메뉴_형식_오류() { + assertSimpleTest(() -> { + runException("제이미,제이콥", "김밥-떡볶이"); + assertThat(output()).contains(ErrorMessage.FORMAT_ERROR.getErrorMessage()); + }); + } + + @Test + void 코치별_못먹는_메뉴_개수_초과_오류() { + assertSimpleTest(() -> { + runException("제이미,제이콥", "김밥,떡볶이,제육볶음"); + assertThat(output()).contains(ErrorMessage.REJECTED_MENU_COUNT_ERROR.getErrorMessage()); + }); + } + + @Test + void 코치별_못먹는_메뉴_존재_오류() { + assertSimpleTest(() -> { + runException("제이미,제이콥", "김밥,해장국"); + assertThat(output()).contains(ErrorMessage.NO_EXIST_MENU_ERROR.getErrorMessage()); + }); + } + + @Test + void 코치별_못먹는_메뉴_중복_오류() { + assertSimpleTest(() -> { + runException("제이미,제이콥", "김밥,김밥"); + assertThat(output()).contains(ErrorMessage.UNIQUE_ERROR.getErrorMessage()); + }); + } } @Override