diff --git a/README.md b/README.md index 8d7e8aee..70483e9d 100644 --- a/README.md +++ b/README.md @@ -1 +1,129 @@ -# java-baseball-precourse \ No newline at end of file +# java-baseball-precourse + +## 목표 + +1부터 9까지 서로 다른 수로 이루어진 3자리 숫자를 맞추는 숫자 야구 게임을 구현한다. + +### 게임 규칙 +- 컴퓨터가 1~9 중 서로 다른 임의의 수 3개를 선택한다. +- 플레이어는 3자리 숫자를 입력하고, 컴퓨터는 힌트를 제공한다. + - **스트라이크**: 같은 수가 같은 자리에 있는 경우 + - **볼**: 같은 수가 다른 자리에 있는 경우 + - **낫싱**: 같은 수가 전혀 없는 경우 +- 3스트라이크 시 게임이 종료되며, 재시작 또는 완전 종료를 선택할 수 있다. +- 잘못된 입력 시 `[ERROR]`로 시작하는 메시지를 출력하고 게임을 계속 진행한다. + +--- + +## 기능 목록 + +### Model + +#### RandomNumberGenerator (랜덤 숫자 생성) +1. `pickNumber` - 숫자 랜덤으로 하나 뽑는 함수 + - 매개변수: 시작숫자, 종료숫자 + - 반환값: 숫자 + - 시작숫자가 종료숫자보다 큰 경우 오류 + +2. `pickUniqueNumbers` - 숫자를 중복 없이 N개 뽑는 함수 + - 매개변수: 시작숫자, 종료숫자, 개수 + - 반환값: 숫자 리스트 + - 범위보다 요구 개수가 많거나 개수가 0 미만이면 오류 + +#### GameResult (게임 결과 객체) +- 필드: ball(볼 개수), strike(스트라이크 개수) + +1. `isGameOver` - 게임 종료 여부 판단 함수 + - 매개변수: 목표 개수 + - 반환값: boolean + +#### BaseballGameJudge (게임 판정) +1. `countBall` - 볼 검사 함수 + - 매개변수: 숫자 리스트, 숫자 리스트 + - 반환값: 숫자 + - 두 리스트에서 위치는 다르지만 같은 숫자가 몇 개인지 검사 + +2. `countStrike` - 스트라이크 검사 함수 + - 매개변수: 숫자 리스트, 숫자 리스트 + - 반환값: 숫자 + - 두 리스트에서 동일한 위치에 동일한 숫자가 몇 개인지 검사 + +3. `judge` - 야구게임 결과 함수 + - 매개변수: 숫자 리스트, 숫자 리스트 + - 반환값: GameResult 객체 + +#### InputValidator (입력 검증) +1. `parseToNumbers` - 문자열을 숫자 리스트로 파싱하는 함수 + - 매개변수: 문자열, 최소값, 최대값, 길이 + - 반환값: 숫자 리스트 + - 숫자 외의 문자가 포함되어 있다면 [ERROR] + - 입력값이 비어있으면 [ERROR] + - 범위/길이/중복 검증을 함께 수행 + +2. `validateRange` - 숫자 범위 검증 함수 + - 매개변수: 숫자 리스트, 최소값, 최대값 + - 반환값: 없음 + - 범위를 벗어난 숫자가 있으면 [ERROR] + +3. `validateLength` - 길이 검사 함수 + - 매개변수: 숫자 리스트, 길이 + - 반환값: 없음 + - 길이가 다르면 [ERROR] + +4. `validateNoDuplicates` - 중복 없는 숫자 리스트인지 검증 함수 + - 매개변수: 숫자 리스트 + - 반환값: 없음 + - 중복이 있으면 [ERROR] + +5. `validateRestartInput` - 재시작 입력 검증 함수 + - 매개변수: 문자열 + - 반환값: boolean + - 잘못된 값이면 [ERROR] + +--- + +### View + +#### InputView (입력) +1. `readNumber` - 숫자 입력 함수 + - 매개변수: 없음 + - 반환값: 문자열 + - 안내 문구 출력 필요 + +2. `readRestartOption` - 재시작/종료 입력 함수 + - 매개변수: 없음 + - 반환값: 문자열 + +#### OutputView (출력) +1. `printResult` - 게임 결과 출력 함수 + - 매개변수: GameResult 객체 + - 반환값: 없음 + - 결과 문구 출력 + +2. `printGameEnd` - 게임 종료 메시지 출력 함수 + - 매개변수: 목표 개수 + - 반환값: 없음 + +3. `printError` - 에러 메시지 출력 함수 + - 매개변수: exception 객체 + - 반환값: 없음 + +--- + +### Controller + +#### GameController (게임 제어) +1. `run` - 전체 진입 함수 + - 매개변수: 없음 + - 반환값: 없음 + - 최상위 제어, 숫자 생성, 단건 함수 반복 호출, 재시작 함수 호출 등 + +2. `playRound` - 단건 함수 + - 매개변수: 숫자 리스트 + - 반환값: boolean + - 숫자 입력, 결과 출력(검사 로직 포함) 호출, 종료 여부 반환 + +3. `handleRestart` - 재시작 함수 + - 매개변수: 없음 + - 반환값: boolean + - 올바른 입력이 들어올 때까지 반복 diff --git a/src/main/java/BaseballGame.java b/src/main/java/BaseballGame.java new file mode 100644 index 00000000..9351df1e --- /dev/null +++ b/src/main/java/BaseballGame.java @@ -0,0 +1,8 @@ +import controller.GameController; + +public class BaseballGame { + public static void main(String[] args) { + GameController controller = new GameController(); + controller.run(); + } +} diff --git a/src/main/java/controller/GameController.java b/src/main/java/controller/GameController.java new file mode 100644 index 00000000..b5513926 --- /dev/null +++ b/src/main/java/controller/GameController.java @@ -0,0 +1,68 @@ +package controller; + +import model.BaseballGameJudge; +import model.GameResult; +import model.InputValidator; +import model.RandomNumberGenerator; +import model.exception.UserException; +import view.InputView; +import view.OutputView; + +import java.util.List; + +public class GameController { + + private static final int NUMBER_SIZE = 3; + private static final int MIN_NUMBER = 1; + private static final int MAX_NUMBER = 9; + + private final RandomNumberGenerator randomNumberGenerator; + private final BaseballGameJudge baseballGameJudge; + private final InputValidator inputValidator; + + private final InputView inputView; + private final OutputView outputView; + + public GameController() { + this.randomNumberGenerator = new RandomNumberGenerator(); + this.baseballGameJudge = new BaseballGameJudge(); + this.inputValidator = new InputValidator(); + + this.inputView = new InputView(); + this.outputView = new OutputView(); + } + + public void run() { + do{ + List result = randomNumberGenerator.pickUniqueNumbers(MIN_NUMBER, MAX_NUMBER, NUMBER_SIZE); +// List result = List.of(1,2,3); + while(!playRound(result)){ + } + outputView.printGameEnd(NUMBER_SIZE); + }while(handleRestart()); + } + + public boolean playRound(List answer) { + try{ + String input = inputView.readNumber(); + List inputList = inputValidator.parseToNumbers(input, MIN_NUMBER, MAX_NUMBER, NUMBER_SIZE); + GameResult judge = baseballGameJudge.judge(answer, inputList); + outputView.printResult(judge); + return judge.isGameOver(NUMBER_SIZE); + }catch(UserException e){ + outputView.printError(e); + } + return false; + } + + public boolean handleRestart() { + while(true){ + try{ + String s = inputView.readRestartOption(); + return inputValidator.validateRestartInput(s); + }catch(UserException e){ + outputView.printError(e); + } + } + } +} diff --git a/src/main/java/model/BaseballGameJudge.java b/src/main/java/model/BaseballGameJudge.java new file mode 100644 index 00000000..3fd9bfe5 --- /dev/null +++ b/src/main/java/model/BaseballGameJudge.java @@ -0,0 +1,37 @@ +package model; + +import model.exception.InvalidInputException; + +import java.util.List; + +import static model.exception.InputErrorCode.*; + + +public class BaseballGameJudge { + + int countBall(List answer, List guess) { + int ball = 0; + for (int i = 0; i < guess.size(); i++) { + int num = guess.get(i); + if (answer.contains(num) && !answer.get(i).equals(num)) { + ball++; + } + } + return ball; + } + + int countStrike(List answer, List guess) { + int strike = 0; + for (int i = 0; i < guess.size(); i++) { + if (answer.get(i).equals(guess.get(i))) { + strike++; + } + } + return strike; + } + + public GameResult judge(List answer, List guess) { + return new GameResult(countBall(answer, guess), countStrike(answer, guess)); + } + +} diff --git a/src/main/java/model/GameResult.java b/src/main/java/model/GameResult.java new file mode 100644 index 00000000..70ab45a9 --- /dev/null +++ b/src/main/java/model/GameResult.java @@ -0,0 +1,25 @@ +package model; + +public class GameResult { + + private int ball; + private int strike; + + public GameResult(int ball, int strike) { + this.ball = ball; + this.strike = strike; + } + + public int getBall() { + return ball; + } + + public int getStrike() { + return strike; + } + + public boolean isGameOver(int numberSize) { + return numberSize == strike; + } + +} diff --git a/src/main/java/model/InputValidator.java b/src/main/java/model/InputValidator.java new file mode 100644 index 00000000..7971df46 --- /dev/null +++ b/src/main/java/model/InputValidator.java @@ -0,0 +1,70 @@ +package model; + +import model.exception.InvalidInputException; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static model.exception.InputErrorCode.*; + +public class InputValidator { + + public List parseToNumbers(String input, int min, int max, int size) { + if (input == null || input.trim().isEmpty()) { + throw new InvalidInputException(INVALID_INPUT_EMPTY); + } + + List numbers = new ArrayList<>(); + for (char c : input.trim().toCharArray()) { + if (!Character.isDigit(c)) { + throw new InvalidInputException(INVALID_INPUT_FORMAT); + } + numbers.add(c - '0'); + } + + validateInput(numbers, min, max, size); + return numbers; + } + + void validateInput(List numbers, int min, int max, int size){ + validateRange(numbers, min, max); + validateNoDuplicates(numbers); + validateLength(numbers, size); + } + + void validateLength(List numbers, int size) { + if(numbers.size() != size) throw new InvalidInputException(INVALID_LENGTH); + } + + void validateRange(List numbers, int min, int max) { + for (Integer num : numbers) { + if (num < min || num > max) { + throw new InvalidInputException(INVALID_NUMBER_RANGE); + } + } + } + + void validateNoDuplicates(List numbers) { + Set seen = new HashSet<>(); + for (Integer num : numbers) { + if (!seen.add(num)) { + throw new InvalidInputException(DUPLICATE_NUMBER); + } + } + } + + public boolean validateRestartInput(String input) { + if (input.isEmpty()) { + throw new InvalidInputException(INVALID_INPUT_EMPTY); + } + if (input.trim().equals("1")) { + return true; + } + if (input.trim().equals("2")) { + return false; + } + throw new InvalidInputException(INVALID_RESTART_INPUT); + } +} diff --git a/src/main/java/model/RandomNumberGenerator.java b/src/main/java/model/RandomNumberGenerator.java new file mode 100644 index 00000000..8302223c --- /dev/null +++ b/src/main/java/model/RandomNumberGenerator.java @@ -0,0 +1,33 @@ +package model; + +import java.util.*; + +public class RandomNumberGenerator { + + private final Random random; + + public RandomNumberGenerator(){ + random = new Random(); + } + + public int pickNumber(int start, int end) { + if (start > end) throw new IllegalArgumentException("숫자의 범위가 올바르지 않습니다."); + return random.nextInt(end-start+1) + start; + } + + public List pickUniqueNumbers(int start, int end, int count) { + if(end-start+1 < count) throw new IllegalArgumentException("원하는 개수의 숫자를 뽑을 수 없습니다"); + if(count<0) throw new IllegalArgumentException("0개 미만의 숫자를 뽑을 수 없습니다."); + List list = new ArrayList<>(); + Set picks = new HashSet<>(); + + while(list.size() < count){ + int pick = pickNumber(start, end); + if(picks.contains(pick)) continue; + list.add(pick); + picks.add(pick); + } + + return list; + } +} diff --git a/src/main/java/model/exception/InputErrorCode.java b/src/main/java/model/exception/InputErrorCode.java new file mode 100644 index 00000000..fe84bd4a --- /dev/null +++ b/src/main/java/model/exception/InputErrorCode.java @@ -0,0 +1,30 @@ +package model.exception; + +public enum InputErrorCode { + + // 입력 관련 + INVALID_INPUT_FORMAT("숫자만 입력할 수 있습니다."), + INVALID_INPUT_EMPTY("입력값이 비어있습니다."), + INVALID_RESTART_INPUT("1 또는 2만 입력할 수 있습니다."), + + // 범위 관련 + INVALID_NUMBER_RANGE("범위를 벗어난 숫자가 있습니다."), + + // 중복 관련 + DUPLICATE_NUMBER("중복된 숫자가 있습니다."), + + // 길이 관련 + INVALID_LENGTH("입력 길이가 올바르지 않습니다.") + + ; + + private final String message; + + InputErrorCode(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/model/exception/InvalidInputException.java b/src/main/java/model/exception/InvalidInputException.java new file mode 100644 index 00000000..14a398cb --- /dev/null +++ b/src/main/java/model/exception/InvalidInputException.java @@ -0,0 +1,8 @@ +package model.exception; + +public class InvalidInputException extends UserException { + + public InvalidInputException(InputErrorCode code) { + super(code); + } +} diff --git a/src/main/java/model/exception/UserException.java b/src/main/java/model/exception/UserException.java new file mode 100644 index 00000000..d5d111bb --- /dev/null +++ b/src/main/java/model/exception/UserException.java @@ -0,0 +1,20 @@ +package model.exception; + +public abstract class UserException extends RuntimeException { + + private final InputErrorCode inputErrorCode; + + public UserException(InputErrorCode code) { + super(code.getMessage()); + this.inputErrorCode = code; + } + + public InputErrorCode getErrorCode() { + return inputErrorCode; + } + + @Override + public String getMessage(){ + return inputErrorCode.getMessage(); + } +} diff --git a/src/main/java/view/InputView.java b/src/main/java/view/InputView.java new file mode 100644 index 00000000..05de8bf7 --- /dev/null +++ b/src/main/java/view/InputView.java @@ -0,0 +1,33 @@ +package view; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; + +public class InputView { + + private final BufferedReader br; + + public InputView(){ + this.br = new BufferedReader(new InputStreamReader(System.in)); + } + + String input(){ + try{ + System.out.flush(); + return br.readLine(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public String readNumber() { + System.out.print("숫자를 입력해주세요 : "); + return input(); + } + + public String readRestartOption() { + System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요."); + return input(); + } +} diff --git a/src/main/java/view/OutputView.java b/src/main/java/view/OutputView.java new file mode 100644 index 00000000..6f7ec3bd --- /dev/null +++ b/src/main/java/view/OutputView.java @@ -0,0 +1,31 @@ +package view; + +import model.GameResult; +import model.exception.UserException; + +public class OutputView { + + private static final String ERROR_PREFIX = "[ERROR] "; + + private String formatResult(GameResult result) { + if (result.getBall() + result.getStrike() == 0) { + return "낫싱"; + } + String s = ""; + if (result.getStrike() != 0) s += result.getStrike() + "스트라이크 "; + if (result.getBall() != 0) s += result.getBall() + "볼"; + return s.trim(); + } + + public void printResult(GameResult result) { + System.out.println(formatResult(result)); + } + + public void printGameEnd(int numberSize){ + System.out.println(numberSize + "개의 숫자를 모두 맞히셨습니다! 게임 끝"); + } + + public void printError(UserException e) { + System.out.println(ERROR_PREFIX + e.getMessage()); + } +} diff --git a/src/test/java/model/BaseballGameJudgeTest.java b/src/test/java/model/BaseballGameJudgeTest.java new file mode 100644 index 00000000..fef3cf74 --- /dev/null +++ b/src/test/java/model/BaseballGameJudgeTest.java @@ -0,0 +1,73 @@ +package model; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class BaseballGameJudgeTest { + + @Test + @DisplayName("countBall: 위치 다른 동일 숫자만 카운트") + void countBall_countsOnlyDifferentPositions() { + BaseballGameJudge judge = new BaseballGameJudge(); + + int ball = judge.countBall(List.of(1, 2, 3), List.of(2, 1, 3)); + + assertThat(ball).isEqualTo(2); + } + + @Test + @DisplayName("countBall: 스트라이크는 볼로 카운트하지 않음") + void countBall_doesNotCountStrikeAsBall() { + BaseballGameJudge judge = new BaseballGameJudge(); + + int ball = judge.countBall(List.of(1, 2, 3), List.of(1, 3, 2)); + + assertThat(ball).isEqualTo(2); + } + + @Test + @DisplayName("countStrike: 같은 위치 같은 숫자 카운트") + void countStrike_countsSamePositionSameNumber() { + BaseballGameJudge judge = new BaseballGameJudge(); + + int strike = judge.countStrike(List.of(1, 2, 3), List.of(1, 4, 3)); + + assertThat(strike).isEqualTo(2); + } + + @Test + @DisplayName("countStrike: 전부 일치 시 길이 반환") + void countStrike_returnsLengthWhenAllMatch() { + BaseballGameJudge judge = new BaseballGameJudge(); + + int strike = judge.countStrike(List.of(7, 8, 9), List.of(7, 8, 9)); + + assertThat(strike).isEqualTo(3); + } + + @Test + @DisplayName("judge: 볼/스트라이크 계산") + void judge_returnsBallAndStrikeCounts() { + BaseballGameJudge judge = new BaseballGameJudge(); + + GameResult result = judge.judge(List.of(4, 2, 5), List.of(2, 4, 5)); + + assertThat(result.getBall()).isEqualTo(2); + assertThat(result.getStrike()).isEqualTo(1); + } + + @Test + @DisplayName("judge: 일치 없음이면 0/0") + void judge_returnsZeroWhenNoMatches() { + BaseballGameJudge judge = new BaseballGameJudge(); + + GameResult result = judge.judge(List.of(1, 2, 3), List.of(4, 5, 6)); + + assertThat(result.getBall()).isZero(); + assertThat(result.getStrike()).isZero(); + } +} diff --git a/src/test/java/model/GameResultTest.java b/src/test/java/model/GameResultTest.java new file mode 100644 index 00000000..95cfa8cf --- /dev/null +++ b/src/test/java/model/GameResultTest.java @@ -0,0 +1,42 @@ +package model; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import static org.assertj.core.api.Assertions.assertThat; + +class GameResultTest { + + @Test + @DisplayName("getter: 생성자 값 반환") + void getters_returnConstructorValues() { + GameResult result = new GameResult(2, 1); + + assertThat(result.getBall()).isEqualTo(2); + assertThat(result.getStrike()).isEqualTo(1); + } + + @Test + @DisplayName("isGameOver: strike==size면 true") + void isGameOver_returnsTrueWhenStrikeMatchesSize() { + GameResult result = new GameResult(0, 3); + + assertThat(result.isGameOver(3)).isTrue(); + } + + @Test + @DisplayName("isGameOver: strike!=size면 false") + void isGameOver_returnsFalseWhenStrikeDoesNotMatchSize() { + GameResult result = new GameResult(1, 2); + + assertThat(result.isGameOver(3)).isFalse(); + } + + @Test + @DisplayName("isGameOver: size=0, strike=0이면 true") + void isGameOver_returnsTrueWhenSizeIsZeroAndStrikeIsZero() { + GameResult result = new GameResult(0, 0); + + assertThat(result.isGameOver(0)).isTrue(); + } +} diff --git a/src/test/java/model/InputValidatorTest.java b/src/test/java/model/InputValidatorTest.java new file mode 100644 index 00000000..50a6ff11 --- /dev/null +++ b/src/test/java/model/InputValidatorTest.java @@ -0,0 +1,163 @@ +package model; + +import model.exception.InputErrorCode; +import model.exception.InvalidInputException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InputValidatorTest { + + private final InputValidator validator = new InputValidator(); + + @Test + @DisplayName("parseToNumbers: 숫자 문자열 파싱") + void parseToNumbers_parsesDigits() { + List numbers = validator.parseToNumbers("123", 1, 9, 3); + + assertThat(numbers).containsExactly(1, 2, 3); + } + + @Test + @DisplayName("parseToNumbers: 앞뒤 공백 제거") + void parseToNumbers_trimsInput() { + List numbers = validator.parseToNumbers(" 123 ", 1, 9, 3); + + assertThat(numbers).containsExactly(1, 2, 3); + } + + @Test + @DisplayName("parseToNumbers: null이면 예외") + void parseToNumbers_throwsOnNull() { + assertInvalidInput(() -> validator.parseToNumbers(null, 1, 9, 3), InputErrorCode.INVALID_INPUT_EMPTY); + } + + @Test + @DisplayName("parseToNumbers: 공백/빈 문자열이면 예외") + void parseToNumbers_throwsOnBlank() { + assertInvalidInput(() -> validator.parseToNumbers(" ", 1, 9, 3), InputErrorCode.INVALID_INPUT_EMPTY); + } + + @Test + @DisplayName("parseToNumbers: 숫자 외 문자가 있으면 예외") + void parseToNumbers_throwsOnNonDigit() { + assertInvalidInput(() -> validator.parseToNumbers("12a", 1, 9, 3), InputErrorCode.INVALID_INPUT_FORMAT); + } + + @Test + @DisplayName("parseToNumbers: 길이 불일치면 예외") + void parseToNumbers_throwsOnInvalidLength() { + assertInvalidInput(() -> validator.parseToNumbers("12", 1, 9, 3), InputErrorCode.INVALID_LENGTH); + } + + @Test + @DisplayName("parseToNumbers: 길이 초과면 예외") + void parseToNumbers_throwsOnTooLongLength() { + assertInvalidInput(() -> validator.parseToNumbers("1234", 1, 9, 3), InputErrorCode.INVALID_LENGTH); + } + + @Test + @DisplayName("parseToNumbers: 범위 밖 숫자면 예외") + void parseToNumbers_throwsOnOutOfRange() { + assertInvalidInput(() -> validator.parseToNumbers("109", 1, 9, 3), InputErrorCode.INVALID_NUMBER_RANGE); + } + + @Test + @DisplayName("parseToNumbers: 중복이면 예외") + void parseToNumbers_throwsOnDuplicate() { + assertInvalidInput(() -> validator.parseToNumbers("112", 1, 9, 3), InputErrorCode.DUPLICATE_NUMBER); + } + + @Test + @DisplayName("validateRange: 범위 내면 통과") + void validateRange_allowsValuesWithinRange() { + validator.validateRange(List.of(1, 2, 3), 1, 3); + } + + @Test + @DisplayName("validateRange: 범위 밖이면 예외") + void validateRange_throwsOnOutOfRange() { + assertInvalidInput(() -> validator.validateRange(List.of(1, 4, 3), 1, 3), InputErrorCode.INVALID_NUMBER_RANGE); + } + + @Test + @DisplayName("validateLength: 길이 일치하면 통과") + void validateLength_allowsMatchingLength() { + validator.validateLength(List.of(1, 2, 3), 3); + } + + @Test + @DisplayName("validateLength: 길이 다르면 예외") + void validateLength_throwsOnMismatchedLength() { + assertInvalidInput(() -> validator.validateLength(List.of(1, 2), 3), InputErrorCode.INVALID_LENGTH); + } + + @Test + @DisplayName("validateNoDuplicates: 중복 없으면 통과") + void validateNoDuplicates_allowsUniqueValues() { + validator.validateNoDuplicates(List.of(1, 2, 3)); + } + + @Test + @DisplayName("validateNoDuplicates: 중복이면 예외") + void validateNoDuplicates_throwsOnDuplicate() { + assertInvalidInput(() -> validator.validateNoDuplicates(List.of(1, 2, 2)), InputErrorCode.DUPLICATE_NUMBER); + } + + @Test + @DisplayName("validateRestartInput: '1'이면 true") + void validateRestartInput_returnsTrueForOne() { + assertThat(validator.validateRestartInput("1")).isTrue(); + } + + @Test + @DisplayName("validateRestartInput: '2'이면 false") + void validateRestartInput_returnsFalseForTwo() { + assertThat(validator.validateRestartInput("2")).isFalse(); + } + + @Test + @DisplayName("validateRestartInput: 앞뒤 공백 허용") + void validateRestartInput_allowsWhitespaceAroundInput() { + assertThat(validator.validateRestartInput(" 1 ")).isTrue(); + assertThat(validator.validateRestartInput(" 2 ")).isFalse(); + } + + @Test + @DisplayName("validateRestartInput: 빈 문자열이면 예외") + void validateRestartInput_throwsOnEmpty() { + assertInvalidInput(() -> validator.validateRestartInput(""), InputErrorCode.INVALID_INPUT_EMPTY); + } + + @Test + @DisplayName("validateRestartInput: null이면 NPE") + void validateRestartInput_throwsOnNull() { + assertThatThrownBy(() -> validator.validateRestartInput(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("validateRestartInput: 공백만 있으면 예외") + void validateRestartInput_throwsOnWhitespaceOnly() { + assertInvalidInput(() -> validator.validateRestartInput(" "), InputErrorCode.INVALID_RESTART_INPUT); + } + + @Test + @DisplayName("validateRestartInput: 그 외 값이면 예외") + void validateRestartInput_throwsOnOtherValues() { + assertInvalidInput(() -> validator.validateRestartInput("3"), InputErrorCode.INVALID_RESTART_INPUT); + } + + private void assertInvalidInput(Runnable action, InputErrorCode expectedCode) { + assertThatThrownBy(action::run) + .isInstanceOf(InvalidInputException.class) + .satisfies(e -> { + InvalidInputException ex = (InvalidInputException) e; + assertThat(ex.getErrorCode()).isEqualTo(expectedCode); + }); + } +} diff --git a/src/test/java/model/MODEL_TEST_CASES.md b/src/test/java/model/MODEL_TEST_CASES.md new file mode 100644 index 00000000..32d24163 --- /dev/null +++ b/src/test/java/model/MODEL_TEST_CASES.md @@ -0,0 +1,79 @@ +# Model JUnit5 테스트 케이스 목록 + +이 문서는 `model` 패키지에 대한 JUnit5 테스트 케이스를 정리한다. + +## RandomNumberGenerator + +### pickNumber(start, end) +- 정상 범위: `start == end`일 때 반환값이 그 값인지 +- 정상 범위: `start < end`일 때 반환값이 `start..end` 범위 내인지 (여러 번 반복) +- 예외: `start > end`이면 `IllegalArgumentException` 발생 + +### pickUniqueNumbers(start, end, count) +- 정상: `count == 0`이면 빈 리스트 반환 +- 정상: `count > 0`이고 범위가 충분할 때 리스트 크기가 `count`인지 +- 정상: 반환 리스트가 모두 `start..end` 범위 내인지 +- 정상: 반환 리스트에 중복이 없는지 +- 예외: `end - start + 1 < count`이면 `IllegalArgumentException` 발생 +- 예외: `count < 0`이면 `IllegalArgumentException` 발생 +- 참고: `start > end`인 경우는 별도 검증이 없으므로 `count > 0`일 때는 `pickNumber`에서 예외가 발생함 + +## GameResult + +### constructor / getters +- 정상: 생성 시 전달한 `ball`, `strike`가 그대로 반환되는지 + +### isGameOver(numberSize) +- 정상: `numberSize == strike`이면 `true` +- 정상: `numberSize != strike`이면 `false` +- 경계: `numberSize == 0`일 때 `strike == 0`이면 `true` + +## BaseballGameJudge + +### countBall(answer, guess) +- 정상: 모든 숫자가 위치만 다를 때 볼 개수 정확히 계산 +- 정상: 같은 숫자가 같은 위치면 볼로 카운트하지 않음 +- 정상: 중복이 없는 입력에서 볼 계산이 기대값인지 +- 참고: 입력 리스트 길이 검증이 없으므로 길이가 다른 경우는 현재 구현상 정의되지 않음 + +### countStrike(answer, guess) +- 정상: 같은 위치 같은 숫자 개수 계산 +- 정상: 전부 일치 시 `strike == 길이` + +### judge(answer, guess) +- 정상: `countBall`과 `countStrike` 결과로 `GameResult` 생성되는지 +- 정상: 볼/스트라이크가 모두 0이면 0/0 반환되는지 + +## InputValidator + +### parseToNumbers(input, min, max, size) +- 정상: 숫자 문자열이 리스트로 변환되는지 +- 정상: 앞뒤 공백이 있는 경우에도 정상 파싱되는지 +- 예외: `null` 입력이면 `InvalidInputException(INVALID_INPUT_EMPTY)` +- 예외: 빈 문자열/공백 문자열이면 `InvalidInputException(INVALID_INPUT_EMPTY)` +- 예외: 숫자 외 문자가 포함되면 `InvalidInputException(INVALID_INPUT_FORMAT)` +- 예외: 길이가 `size`와 다르면 `InvalidInputException(INVALID_LENGTH)` +- 예외: 길이가 `size`보다 길면 `InvalidInputException(INVALID_LENGTH)` +- 예외: 범위 밖 숫자가 있으면 `InvalidInputException(INVALID_NUMBER_RANGE)` +- 예외: 중복이 있으면 `InvalidInputException(DUPLICATE_NUMBER)` + +### validateRange(numbers, min, max) +- 정상: 모두 범위 내면 예외 없음 +- 예외: 범위 밖 숫자가 하나라도 있으면 `InvalidInputException(INVALID_NUMBER_RANGE)` + +### validateLength(numbers, size) +- 정상: 길이가 같으면 예외 없음 +- 예외: 길이가 다르면 `InvalidInputException(INVALID_LENGTH)` + +### validateNoDuplicates(numbers) +- 정상: 중복 없으면 예외 없음 +- 예외: 중복 있으면 `InvalidInputException(DUPLICATE_NUMBER)` + +### validateRestartInput(input) +- 정상: `"1"`이면 `true` +- 정상: `"2"`이면 `false` +- 정상: 앞뒤 공백이 있어도 `"1"`/`"2"`는 허용 +- 예외: `null`이면 `NullPointerException` +- 예외: 빈 문자열이면 `InvalidInputException(INVALID_INPUT_EMPTY)` +- 예외: 공백만 있는 문자열이면 `InvalidInputException(INVALID_RESTART_INPUT)` +- 예외: 그 외 문자열이면 `InvalidInputException(INVALID_RESTART_INPUT)` diff --git a/src/test/java/model/RandomNumberGeneratorTest.java b/src/test/java/model/RandomNumberGeneratorTest.java new file mode 100644 index 00000000..c7acaa1b --- /dev/null +++ b/src/test/java/model/RandomNumberGeneratorTest.java @@ -0,0 +1,94 @@ +package model; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RandomNumberGeneratorTest { + + @Test + @DisplayName("pickNumber: 범위 내 값 반환") + void pickNumber_returnsValueWithinRange() { + RandomNumberGenerator generator = new RandomNumberGenerator(); + + for (int i = 0; i < 100; i++) { + int value = generator.pickNumber(1, 9); + assertThat(value).isBetween(1, 9); + } + } + + @Test + @DisplayName("pickNumber: start==end이면 해당 값 반환") + void pickNumber_returnsStartWhenRangeIsSingleValue() { + RandomNumberGenerator generator = new RandomNumberGenerator(); + + int value = generator.pickNumber(5, 5); + + assertThat(value).isEqualTo(5); + } + + @Test + @DisplayName("pickNumber: start가 end보다 크면 예외") + void pickNumber_throwsWhenStartGreaterThanEnd() { + RandomNumberGenerator generator = new RandomNumberGenerator(); + + assertThatThrownBy(() -> generator.pickNumber(5, 3)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("pickUniqueNumbers: count가 0이면 빈 리스트") + void pickUniqueNumbers_returnsEmptyListWhenCountIsZero() { + RandomNumberGenerator generator = new RandomNumberGenerator(); + + List numbers = generator.pickUniqueNumbers(1, 9, 0); + + assertThat(numbers).isEmpty(); + } + + @Test + @DisplayName("pickUniqueNumbers: 범위 내 중복 없는 리스트") + void pickUniqueNumbers_returnsUniqueNumbersWithinRange() { + RandomNumberGenerator generator = new RandomNumberGenerator(); + + List numbers = generator.pickUniqueNumbers(1, 9, 3); + + assertThat(numbers).hasSize(3); + assertThat(numbers).allSatisfy(n -> assertThat(n).isBetween(1, 9)); + Set unique = new HashSet<>(numbers); + assertThat(unique).hasSize(3); + } + + @Test + @DisplayName("pickUniqueNumbers: 범위보다 많은 개수면 예외") + void pickUniqueNumbers_throwsWhenCountExceedsRange() { + RandomNumberGenerator generator = new RandomNumberGenerator(); + + assertThatThrownBy(() -> generator.pickUniqueNumbers(1, 2, 3)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("pickUniqueNumbers: count가 음수면 예외") + void pickUniqueNumbers_throwsWhenCountIsNegative() { + RandomNumberGenerator generator = new RandomNumberGenerator(); + + assertThatThrownBy(() -> generator.pickUniqueNumbers(1, 9, -1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("pickUniqueNumbers: start>end이고 count>0이면 예외") + void pickUniqueNumbers_throwsWhenStartGreaterThanEndAndCountPositive() { + RandomNumberGenerator generator = new RandomNumberGenerator(); + + assertThatThrownBy(() -> generator.pickUniqueNumbers(5, 3, 1)) + .isInstanceOf(IllegalArgumentException.class); + } +}