diff --git a/README.md b/README.md index bd90ef0247..75a70b3e6f 100644 --- a/README.md +++ b/README.md @@ -1 +1,23 @@ -# java-calculator-precourse \ No newline at end of file +# java-calculator-precourse + +## 구현할 기능 목록 + +| 번호 | 기능 설명 | +|----|---------------------------------------------------| +| 1 | 기본 구분자(쉼표, 콜론) 외의 구분자(공백, 특수 문자 포함)가 입력될 경우 예외 처리 | +| 2 | 여러 숫자 입력에서 잘못된 구분자가 포함된 경우 예외 처리 | +| 3 | 입력 값에 영어 또는 한글이 구분자로 포함된 경우 예외 처리 | +| 4 | 음수가 입력된 경우 예외 처리 | +| 5 | 입력 문자열이 숫자 또는 "//"로 시작하지 않으면 예외 처리 | +| 6 | 커스텀 구분자가 정의될 때 `\n`이 없을 경우 예외 처리 | +| 7 | 쉼표와 콜론을 혼합 구분자로 사용했을 때 정상적으로 합산 결과 반환 | +| 8 | 커스텀 구분자를 사용한 경우 정상적으로 합산 결과 반환 | +| 9 | 커스텀 구분자만 있을 때 값이 없으면 0 반환 | +| 10 | 커스텀 구분자를 사용하고 숫자가 하나만 들어온 경우 그 숫자 반환 | +| 11 | 쉼표, 콜론 외에도 여러 커스텀 구분자를 파이프로 구분하여 사용 가능 | +| 12 | 커스텀 구분자에 쉼표가 포함되었을 경우 예외 처리 | +| 13 | 커스텀 구분자가 비어 있을 때 기본 구분자만 사용하여 정상적인 합산 결과 반환 | +| 14 | 커스텀 구분자에 숫자가 포함된 경우 예외 처리 | +| 15 | 파이프(`\|`)가 구분자 이외의 위치에서 사용되면 예외 처리 | +| 16 | 입력을 받아오는 기능 | +| 17 | 결과를 출력하는 기능 | diff --git a/src/main/java/calculator/Application.java b/src/main/java/calculator/Application.java index 573580fb40..8c8aa8f4d1 100644 --- a/src/main/java/calculator/Application.java +++ b/src/main/java/calculator/Application.java @@ -1,7 +1,10 @@ package calculator; +import calculator.io.CalculatorIO; + public class Application { + public static void main(String[] args) { - // TODO: 프로그램 구현 + CalculatorIO.add(); } } diff --git a/src/main/java/calculator/global/instance/Messages.java b/src/main/java/calculator/global/instance/Messages.java new file mode 100644 index 0000000000..698d57fec7 --- /dev/null +++ b/src/main/java/calculator/global/instance/Messages.java @@ -0,0 +1,22 @@ +package calculator.global.instance; + +public class Messages { + + public static final String DESCRIPTION = """ + 문자열 덧셈 계산기입니다. + 쉼표(,) 또는 콜론(:)을 구분자로 가지는 문자열을 전달하는 경우 구분자를 기준으로 분리한 각 숫자의 합을 반환한다. + 앞의 기본 구분자(쉼표, 콜론) 외에 커스텀 구분자를 지정할 수 있다. 커스텀 구분자는 문자열 앞부분의 "//"와 "\\n" 사이에 위치하는 문자를 커스텀 구분자로 사용한다. + 또한 커스텀 구분자를 여러 개 사용하고 싶으면 | 를 이용해서 구분해서 등록해주세요. + (단, | 는 커스텀 구분자로 사용이 불가능합니다.) + """; + public static final String INPUT_PROMPT = "덧셈할 문자열을 입력해 주세요."; + public static final String RESULT = "결과 : "; + + public static final String INVALID_DELIMITER_ERROR = "구분자가 적절하지 않습니다"; + public static final String INVALID_CHARACTER_ERROR = "글자는 들어올 수 없습니다"; + public static final String NEGATIVE_NUMBER_ERROR = "음수는 허용되지 않습니다"; + public static final String INVALID_STARTING_CHARACTER_ERROR = "잘못된 형식입니다. 숫자 또는 '//'로 시작해야 합니다."; + public static final String MISSING_NEWLINE_AFTER_CUSTOM_DELIMITER_ERROR = "잘못된 형식입니다. 커스텀 구분자 뒤에 '\\n'이 있어야 합니다."; + public static final String NUMBER_AS_CUSTOM_DELIMITER_ERROR = "숫자는 커스텀 구분자로 사용할 수 없습니다"; + public static final String PIPE_MISUSED_AS_DELIMITER_ERROR = "커스텀 구분자 등록의 형식이 잘못되었습니다. 파이프(`|`)는 커스텀 구분자를 구분하는 용도로만 사용할 수 있습니다"; +} diff --git a/src/main/java/calculator/io/CalculatorIO.java b/src/main/java/calculator/io/CalculatorIO.java new file mode 100644 index 0000000000..83a6dd573e --- /dev/null +++ b/src/main/java/calculator/io/CalculatorIO.java @@ -0,0 +1,23 @@ +package calculator.io; + +import static calculator.global.instance.Messages.DESCRIPTION; +import static calculator.global.instance.Messages.INPUT_PROMPT; +import static calculator.global.instance.Messages.RESULT; + +import calculator.service.CalculatorService; +import camp.nextstep.edu.missionutils.Console; + +public class CalculatorIO { + + public static void add() { + System.out.println(DESCRIPTION); + System.out.println(INPUT_PROMPT); + try { + String stringInput = Console.readLine(); // 사용자 입력 받기 + int result = CalculatorService.add(stringInput); // 덧셈 계산 + System.out.println(RESULT + result); + } finally { + Console.close(); // Scanner 자원 해제 + } + } +} diff --git a/src/main/java/calculator/service/CalculatorService.java b/src/main/java/calculator/service/CalculatorService.java new file mode 100644 index 0000000000..c22d6fb9f3 --- /dev/null +++ b/src/main/java/calculator/service/CalculatorService.java @@ -0,0 +1,75 @@ +package calculator.service; + +import calculator.global.instance.Messages; + +public class CalculatorService { + + public static int add(String input) { + if (input == null || input.isEmpty()) { + return 0; // 빈 문자열 또는 null이면 0 반환 + } + + // 입력에서 \\n을 실제 개행 문자로 변환 + input = input.replace("\\n", "\n"); + + String delimiter = ",|:"; // 기본 구분자 쉼표와 콜론 + if (input.startsWith("//")) { + int delimiterIndex = input.indexOf("\n"); // 실제 개행 문자를 찾음 + if (delimiterIndex == -1) { + throw new IllegalArgumentException(Messages.MISSING_NEWLINE_AFTER_CUSTOM_DELIMITER_ERROR); + } + String customDelimiter = input.substring(2, delimiterIndex); // 커스텀 구분자 추출 + if (!customDelimiter.isEmpty()) { + delimiter = extractCustomDelimiters(customDelimiter); // 커스텀 구분자가 있는 경우 추출 + } + input = input.substring(delimiterIndex + 1); // 개행 뒤의 숫자 부분만 남김 + } + + if (input.isEmpty()) { + return 0; // 숫자가 없으면 0 반환 + } + + // 입력 값을 구분자로 분리한 후 계산 + String[] numbers = input.split(delimiter); + int sum = 0; + + for (String number : numbers) { + if (!number.trim().isEmpty()) { // 빈 값이 아닌 경우만 처리 + int num = parsePositiveInt(number.trim()); // 양수만 허용 + sum += num; + } + } + + return sum; + } + + private static String extractCustomDelimiters(String delimiterPart) { + if (delimiterPart.contains("|")) { + String[] customDelimiters = delimiterPart.split("\\|"); + for (int i = 0; i < customDelimiters.length; i++) { + customDelimiters[i] = escapeSpecialChars(customDelimiters[i]); // 특수 문자를 이스케이프 + } + return String.join("|", customDelimiters); + } else { + return escapeSpecialChars(delimiterPart); // 단일 구분자인 경우도 이스케이프 처리 + } + } + + // 정규식 메타 문자를 이스케이프 처리하는 메소드 + private static String escapeSpecialChars(String delimiter) { + return delimiter.replaceAll("([\\[\\]{}()*+?^$\\\\.|])", "\\\\$1"); + } + + // 양수만 허용하고 숫자가 아니면 예외 처리 + private static int parsePositiveInt(String number) { + try { + int num = Integer.parseInt(number); + if (num < 0) { + throw new IllegalArgumentException(Messages.NEGATIVE_NUMBER_ERROR); + } + return num; + } catch (NumberFormatException e) { + throw new IllegalArgumentException(Messages.INVALID_CHARACTER_ERROR); + } + } +} diff --git a/src/test/java/calculator/service/CalculatorServiceTest.java b/src/test/java/calculator/service/CalculatorServiceTest.java new file mode 100644 index 0000000000..f99940fb11 --- /dev/null +++ b/src/test/java/calculator/service/CalculatorServiceTest.java @@ -0,0 +1,213 @@ +package calculator.service; + +import static calculator.global.instance.Messages.INVALID_CHARACTER_ERROR; +import static calculator.global.instance.Messages.MISSING_NEWLINE_AFTER_CUSTOM_DELIMITER_ERROR; +import static calculator.global.instance.Messages.NEGATIVE_NUMBER_ERROR; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("계산기 로직 테스트") +class CalculatorServiceTest { + + @DisplayName("구분자가 잘못 들어 왔을 시, 에러 발생") + @ParameterizedTest + @ValueSource(strings = {"1;2", // 세미콜론은 기본 구분자가 아님 + "1|2", // 파이프는 기본 구분자가 아님 + "1 2", // 공백은 기본 구분자가 아님 + "1/2" // 슬래시는 기본 구분자가 아님 + }) + void add_invalidDelimiter_throwsException(String input) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> CalculatorService.add(input)); + + assertEquals(INVALID_CHARACTER_ERROR, exception.getMessage()); + } + + @DisplayName("여러 숫자 입력에 대한 구분자가 잘못 들어 왔을 시, 에러 발생") + @ParameterizedTest + @CsvSource({"'1,2;3,4'", // 첫 번째 잘못된 구분자 (세미콜론) + "'1,2,3;4'", // 두 번째 잘못된 구분자 (세미콜론) + "'1;2,3,4'", // 첫 번째 잘못된 구분자 (세미콜론) + "'1,2:3|4'", // 네 번째 잘못된 구분자 (파이프) + "'1:2,3/4'", // 네 번째 잘못된 구분자 (슬래시) + "'9:10,11/12'", // 네 번째 잘못된 구분자 (슬래시), 네 개의 숫자 + "'9:10,11/12,13'", // 네 번째 잘못된 구분자 (슬래시), 네 개의 숫자 + }) + void add_validAndInvalidMixedDelimiters_throwsException(String input) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> CalculatorService.add(input)); + + assertEquals(INVALID_CHARACTER_ERROR, exception.getMessage()); + } + + @DisplayName("구분자 이외의 글자 입력이 들어오면, 에러 발생") + @ParameterizedTest + @CsvSource({"'1a2,3'", // 영어 'a'가 구분자로 들어온 경우 + "'1,2b3'", // 영어 'b'가 구분자로 들어온 경우 + "'1가2:3'", // 한글 '가'가 구분자로 들어온 경우 + "'1,나2,3'", // 한글 '나'가 구분자로 들어온 경우 + "'1,2c3,4'", // 영어 'c'가 구분자로 들어온 경우 + "'1,2,3한4'", // 한글 '한'이 구분자로 들어온 경우 + "'1d2:3,4'" // 영어 'd'가 구분자로 들어온 경우 + }) + void add_invalidCharacterDelimiter_throwsException(String input) { + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> CalculatorService.add(input)); + + assertEquals(INVALID_CHARACTER_ERROR, exception.getMessage()); + } + + @DisplayName("음수 입력이 들어오면, 에러 발생") + @ParameterizedTest + @ValueSource(strings = {"1,-2,3", // 음수가 두 번째 위치 + "-1,2,3", // 음수가 첫 번째 위치 + "1,2,-3", // 음수가 세 번째 위치 + "-1,-2,3", // 음수가 첫 번째와 두 번째 위치 + "1,-2:-3" // 음수가 두 번째와 세 번째 위치, 콜론과 쉼표 혼합 + }) + void add_negativeNumber_throwsException(String input) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> CalculatorService.add(input)); + + assertEquals(NEGATIVE_NUMBER_ERROR, exception.getMessage()); + } + + @DisplayName("입력 문자열의 시작이 잘못되면, 에러 발생") + @ParameterizedTest + @ValueSource(strings = {"a1,2,3", // 첫 글자가 문자 + "#1,2,3", // 첫 글자가 특수 문자 + "@//;\n1;2;3", // 커스텀 구분자가 아닌 특수 문자로 시작 + ";1,2,3" // 잘못된 특수 문자로 시작 + }) + void add_invalidStartingCharacter_throwsException(String input) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> CalculatorService.add(input)); + + assertEquals(INVALID_CHARACTER_ERROR, exception.getMessage()); + } + + @DisplayName("커스텀 구분자 정의 시 '\\n'이 누락되면, IllegalArgumentException 발생") + @ParameterizedTest + @ValueSource(strings = {"//;1;2;3", // '\n'이 없고 바로 숫자가 나옴 + "//|1|2|3", // '\n'이 없고 바로 숫자가 나옴 + "//.123", // '\n'이 없이 바로 숫자가 나옴 + "//#1#2" // '\n'이 없고 바로 숫자가 나옴 + }) + void add_missingNewlineAfterCustomDelimiter_throwsException(String input) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> CalculatorService.add(input)); + + assertEquals(MISSING_NEWLINE_AFTER_CUSTOM_DELIMITER_ERROR, exception.getMessage()); + } + + @DisplayName("숫자가 커스텀 구분자로 사용되면 IllegalArgumentException 발생") + @ParameterizedTest + @ValueSource(strings = {"//1\n1,2,3", // 숫자 '1'이 커스텀 구분자로 사용됨 + "//2\n2,3,4", // 숫자 '2'가 커스텀 구분자로 사용됨 + "//3\n3:4:5", // 숫자 '3'이 커스텀 구분자로 사용됨 + }) + void add_withNumberAsCustomDelimiter_throwsException(String input) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> CalculatorService.add(input)); + + assertEquals(INVALID_CHARACTER_ERROR, exception.getMessage()); + } + + @DisplayName("파이프(`|`)가 구분자 외에 다른 위치에서 사용되면 IllegalArgumentException 발생") + @ParameterizedTest + @ValueSource(strings = {"//|\n1,2,3", // | 는 커스텀 구분자가 될 수 없음 + "//||\n2,3,4", // | 는 커스텀 구분자가 될 수 없음 + "//|||\n3:4:5", // | 는 커스텀 구분자가 될 수 없음 + }) + void add_withNumberOrPipeMisusedAsCustomDelimiter_throwsException(String input) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> CalculatorService.add(input)); + + assertEquals(INVALID_CHARACTER_ERROR, exception.getMessage()); + } + + @DisplayName("커스텀 구분자에 대한 성공 로직 테스트") + @ParameterizedTest + @CsvSource({"'//;\n1;2;3', 6", // 세미콜론을 커스텀 구분자로 사용 + "'//@\n2@3@4', 9", // @를 커스텀 구분자로 사용 + "'//#\n1#2#3#4', 10", // #을 커스텀 구분자로 사용 + "'//.\n5.6.7', 18" // 점(.)을 커스텀 구분자로 사용 + }) + void add_withCustomDelimiters_returnsSum(String input, int expectedSum) { + int result = CalculatorService.add(input); + assertEquals(expectedSum, result); + } + + @DisplayName("구분자에 대한 성공 로직 테스트") + @ParameterizedTest + @CsvSource({"'1,2,3', 6", // 쉼표 구분자 + "'1:2:3', 6", // 콜론 구분자 + "'1,2:3', 6", // 쉼표와 콜론 혼합 구분자 + }) + void add_withValidInput_returnsSum(String input, int expectedSum) { + int result = CalculatorService.add(input); + assertEquals(expectedSum, result); + } + + @DisplayName("커스텀 구분자를 사용하더라도 값이 없으면 0을 반환") + @ParameterizedTest + @ValueSource(strings = {"//;\n", // 커스텀 구분자만 있고 숫자가 없음 + "//#\n", // 커스텀 구분자만 있고 숫자가 없음 + "//@\n", // 커스텀 구분자만 있고 숫자가 없음 + }) + void add_customDelimiterWithNoValues_returnsZero(String input) { + int result = CalculatorService.add(input); + assertEquals(0, result); + } + + @DisplayName("커스텀 구분자를 사용하고 숫자가 하나만 들어왔을 때 해당 숫자를 반환") + @ParameterizedTest + @CsvSource({"'//;\n5', 5", // 세미콜론 구분자와 숫자 5 + "'//#\n15', 15", // 샵 구분자와 숫자 15 + "'//@\n20', 20", // 앳 구분자와 숫자 20 + "'//.\n25', 25" // 점 구분자와 숫자 25 + }) + void add_customDelimiterWithSingleNumber_returnsNumber(String input, int expected) { + int result = CalculatorService.add(input); + assertEquals(expected, result); + } + + @DisplayName("커스텀 구분자가 없는 경우 성공 로직 테스트") + @ParameterizedTest + @CsvSource({"'//\n1,2,3', 6", // 커스텀 구분자가 없고 쉼표 구분자 사용 + "'//\n1:2:3', 6", // 커스텀 구분자가 없고 콜론 구분자 사용 + "'//\n4,5:6', 15" // 커스텀 구분자가 없고 쉼표와 콜론 혼합 사용 + }) + void add_withEmptyCustomDelimiter_returnsSum(String input, int expectedSum) { + int result = CalculatorService.add(input); + assertEquals(expectedSum, result); + } + + @DisplayName("글자 커스텀 구분자를 사용한 성공 로직 테스트") + @ParameterizedTest + @CsvSource({"'//a\n1a2a3', 6", // 'a'를 커스텀 구분자로 사용 + "'//ㄱ\n1ㄱ2ㄱ3', 6", // 'ㄱ'를 커스텀 구분자로 사용 + "'//가\n1가2가3', 6" // '가'를 커스텀 구분자로 사용 + }) + void add_withCustomLetterDelimiters_returnsSum(String input, int expectedSum) { + int result = CalculatorService.add(input); + assertEquals(expectedSum, result); + } + + @DisplayName("여러 커스텀 구분자를 파이프로 구분하여 처리하는 성공 로직 테스트") + @ParameterizedTest + @CsvSource({"'//#|*|%\n5#6*7%8', 26", // 해시, 별표, 퍼센트를 커스텀 구분자로 사용 + "'//@|!|^\n9@10!11^12', 42", // 앳, 느낌표, 캐럿을 커스텀 구분자로 사용 + "'//가|나|다\n1가2나3다4', 10" // 한글 구분자 (가, 나, 다) 사용 + }) + void add_withMultipleCustomDelimitersUsingPipe_returnsSum(String input, int expectedSum) { + int result = CalculatorService.add(input); + assertEquals(expectedSum, result); + } +}