diff --git a/README.md b/README.md index 8d7e8aee..391c720d 100644 --- a/README.md +++ b/README.md @@ -1 +1,28 @@ -# java-baseball-precourse \ No newline at end of file +# java-baseball-precourse + +## Domain + +- [x] 컴퓨터의 3자리의 수를 맞춰야 한다. + - [x] 3자리의 수는 1~9까지 서로 다른 수로 랜덤하게 이루어진다. + - [x] 같은 수가 같은 자리에 있으면 '스트라이크' + - [x] 같은 수가 다른 자리에 있으면 '볼' + - [x] 같은 수가 없으면 '낫싱' +- [x] 사용자가 잘못된 값을 입력해도 게임을 계속 진행할 수 있어야 한다. + - [x] 에러 메시지를 띄운다. + - [x] 다시 입력을 받는다. + +## Input + +- [x] 사용자는 컴퓨터가 생각하고 있는 3개의 숫자를 입력한다. +- [x] 게임 종료 시 재시작 또는 종료 여부를 입력한다. + - [x] '1' 입력 시 재시작 + - [x] '2' 입력 시 종료 + +## Output + +- [x] 컴퓨터는 플레이어가 입력한 숫자에 대한 결과를 출력한다. +- [x] 사용자가 잘못된 값을 입력할 경우 에러 메시지를 출력한다. + - [x] 에러 메시지는 [ERROR]로 시작한다. +- [x] 게임 종료 시 문구를 출력한다. + - [x] 게임 끝 문구 출력 + - [x] 종료 또는 재시작을 선택할 수 있는 문구 출력 diff --git a/build.gradle b/build.gradle index 20a92c9e..8b5ed857 100644 --- a/build.gradle +++ b/build.gradle @@ -23,3 +23,6 @@ dependencies { test { useJUnitPlatform() } + +compileJava.options.encoding = 'UTF-8' +compileTestJava.options.encoding = 'UTF-8' \ No newline at end of file diff --git a/src/main/java/.gitkeep b/src/main/java/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/Application.java b/src/main/java/Application.java new file mode 100644 index 00000000..848cbc0b --- /dev/null +++ b/src/main/java/Application.java @@ -0,0 +1,16 @@ +import controller.BaseballGameController; +import model.generator.NumberGenerator; +import model.generator.RandomNumberGenerator; +import view.InputView; +import view.OutputView; + +public class Application { + public static void main(String[] args) { + InputView inputView = new InputView(); + OutputView outputView = new OutputView(); + NumberGenerator numberGenerator = new RandomNumberGenerator(); + + BaseballGameController gameController = new BaseballGameController(inputView, outputView, numberGenerator); + gameController.run(); + } +} diff --git a/src/main/java/controller/BaseballGameController.java b/src/main/java/controller/BaseballGameController.java new file mode 100644 index 00000000..592ffb81 --- /dev/null +++ b/src/main/java/controller/BaseballGameController.java @@ -0,0 +1,65 @@ +package controller; + +import java.util.List; + +import model.computer.Computer; +import model.game.GameCommand; +import model.game.Result; +import model.generator.NumberGenerator; +import model.player.Player; +import view.InputView; +import view.OutputView; + +public class BaseballGameController { + private static final int GAME_CLEAR_STRIKES = 3; + private final InputView inputView; + private final OutputView outputView; + private final NumberGenerator numberGenerator; + + public BaseballGameController(InputView inputView, OutputView outputView, NumberGenerator numberGenerator) { + this.inputView = inputView; + this.outputView = outputView; + this.numberGenerator = numberGenerator; + } + + public void run() { + do { + start(); + } while (shouldRestart()); + } + + private void start() { + Computer computer = new Computer(numberGenerator); + Result result; + + do { + List playerNumber = getPlayer().getNumber(); + result = computer.calculate(playerNumber); + outputView.printResult(result); + } while (result.strikes() != GAME_CLEAR_STRIKES); + + outputView.printGameClearMessage(); + } + + private boolean shouldRestart() { + while (true) { + try { + String input = inputView.readRestartOrQuit(); + return GameCommand.from(input).isRestart(); + } catch (IllegalArgumentException e) { + outputView.printErrorMessage(e.getMessage()); + } + } + } + + private Player getPlayer() { + while (true) { + try { + String input = inputView.readPlayerNumber(); + return new Player(input); + } catch (IllegalArgumentException e) { + outputView.printErrorMessage(e.getMessage()); + } + } + } +} diff --git a/src/main/java/model/computer/Computer.java b/src/main/java/model/computer/Computer.java new file mode 100644 index 00000000..5cc606d7 --- /dev/null +++ b/src/main/java/model/computer/Computer.java @@ -0,0 +1,43 @@ +package model.computer; + +import java.util.List; + +import model.game.GameNumber; +import model.game.Result; +import model.generator.NumberGenerator; + +public class Computer { + private static final int NUMBER_LENGTH = 3; + private final GameNumber gameNumber; + + public Computer(NumberGenerator numberGenerator) { + this.gameNumber = new GameNumber(numberGenerator.generate()); + } + + public Result calculate(List playerNumber) { + int strikes = countStrikes(playerNumber); + int balls = countBalls(playerNumber) - strikes; + + return new Result(strikes, balls); + } + + private int countStrikes(List playerNumbers) { + int strikes = 0; + for (int i = 0; i < NUMBER_LENGTH; i++) { + if (gameNumber.getNumber().get(i).equals(playerNumbers.get(i))) { + strikes++; + } + } + return strikes; + } + + private int countBalls(List playerNumbers) { + int balls = 0; + for (int num : playerNumbers) { + if (gameNumber.getNumber().contains(num)) { + balls++; + } + } + return balls; + } +} diff --git a/src/main/java/model/game/GameCommand.java b/src/main/java/model/game/GameCommand.java new file mode 100644 index 00000000..71acd9e7 --- /dev/null +++ b/src/main/java/model/game/GameCommand.java @@ -0,0 +1,27 @@ +package model.game; + +public enum GameCommand { + RESTART("1"), + QUIT("2"); + + private static final String ERROR_INVALID_COMMAND = "1 또는 2를 입력해야 합니다."; + + private final String command; + + GameCommand(String command) { + this.command = command; + } + + public static GameCommand from(String input) { + for (GameCommand command : values()) { + if (command.command.equals(input)) { + return command; + } + } + throw new IllegalArgumentException(ERROR_INVALID_COMMAND); + } + + public boolean isRestart() { + return this == RESTART; + } +} diff --git a/src/main/java/model/game/GameNumber.java b/src/main/java/model/game/GameNumber.java new file mode 100644 index 00000000..7e62c0c9 --- /dev/null +++ b/src/main/java/model/game/GameNumber.java @@ -0,0 +1,84 @@ +package model.game; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class GameNumber { + private static final int NUMBER_LENGTH = 3; + private static final int MIN_NUMBER = 1; + private static final int MAX_NUMBER = 9; + + private static final String ERROR_INVALID_LENGTH = "[ERROR] 숫자는 3자리여야 합니다."; + private static final String ERROR_NOT_A_DIGIT = "[ERROR] 숫자만 입력해야 합니다."; + private static final String ERROR_INVALID_RANGE = "[ERROR] 숫자는 1부터 9까지만 가능합니다."; + private static final String ERROR_DUPLICATED_DIGIT = "[ERROR] 숫자는 서로 중복될 수 없습니다."; + + private final List number; + + public GameNumber(List number) { + validateNumber(number); + + this.number = List.copyOf(number); + } + + public GameNumber(String number) { + List parsedNumber = toDigits(number); + validateNumber(parsedNumber); + this.number = parsedNumber; + } + + public List getNumber() { + return number; + } + + private List toDigits(String number) { + if (number == null) { + throw new IllegalArgumentException(ERROR_INVALID_LENGTH); + } + + List digits = new ArrayList<>(NUMBER_LENGTH); + for (char num : number.toCharArray()) { + validateIsNumber(num); + digits.add(num - '0'); + } + return digits; + } + + private static void validateNumber(List number) { + validateLength(number); + + Set uniqueNumbers = new HashSet<>(); + for (Integer num : number) { + validateNumRange(num); + uniqueNumbers.add(num); + } + + validateDuplication(uniqueNumbers); + } + + private static void validateLength(List number) { + if (number.size() != NUMBER_LENGTH) { + throw new IllegalArgumentException(ERROR_INVALID_LENGTH); + } + } + + private static void validateIsNumber(char num) { + if (!Character.isDigit(num)) { + throw new IllegalArgumentException(ERROR_NOT_A_DIGIT); + } + } + + private static void validateNumRange(Integer num) { + if (num < MIN_NUMBER || num > MAX_NUMBER) { + throw new IllegalArgumentException(ERROR_INVALID_RANGE); + } + } + + private static void validateDuplication(Set uniqueNumbers) { + if (uniqueNumbers.size() != NUMBER_LENGTH) { + throw new IllegalArgumentException(ERROR_DUPLICATED_DIGIT); + } + } +} diff --git a/src/main/java/model/game/Result.java b/src/main/java/model/game/Result.java new file mode 100644 index 00000000..132c1e5a --- /dev/null +++ b/src/main/java/model/game/Result.java @@ -0,0 +1,33 @@ +package model.game; + +public record Result(int strikes, int balls) { + + private static final String NOTHING_MESSAGE = "낫싱"; + private static final String STRIKE_SUFFIX = "스트라이크"; + private static final String BALL_SUFFIX = "볼"; + + @Override + public String toString() { + if (isNothing()) { + return NOTHING_MESSAGE; + } + + StringBuilder sb = new StringBuilder(); + if (strikes > 0) { + sb.append(strikes).append(STRIKE_SUFFIX); + } + + if (balls > 0) { + if (!sb.isEmpty()) { + sb.append(" "); + } + sb.append(balls).append(BALL_SUFFIX); + } + + return sb.toString(); + } + + public boolean isNothing() { + return strikes == 0 && balls == 0; + } +} diff --git a/src/main/java/model/generator/NumberGenerator.java b/src/main/java/model/generator/NumberGenerator.java new file mode 100644 index 00000000..e15e89ad --- /dev/null +++ b/src/main/java/model/generator/NumberGenerator.java @@ -0,0 +1,7 @@ +package model.generator; + +import java.util.List; + +public interface NumberGenerator { + List generate(); +} diff --git a/src/main/java/model/generator/RandomNumberGenerator.java b/src/main/java/model/generator/RandomNumberGenerator.java new file mode 100644 index 00000000..072df9d9 --- /dev/null +++ b/src/main/java/model/generator/RandomNumberGenerator.java @@ -0,0 +1,26 @@ +package model.generator; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +public class RandomNumberGenerator implements NumberGenerator { + + private static final int NUMBER_LENGTH = 3; + private static final int MIN_DIGIT = 1; + private static final int MAX_DIGIT = 9; + + private final Random random = new Random(); + + @Override + public List generate() { + List availableDigits = new ArrayList<>(); + for (int num = MIN_DIGIT; num <= MAX_DIGIT; num++) { + availableDigits.add(num); + } + + Collections.shuffle(availableDigits, random); + return new ArrayList<>(availableDigits.subList(0, NUMBER_LENGTH)); + } +} diff --git a/src/main/java/model/player/Player.java b/src/main/java/model/player/Player.java new file mode 100644 index 00000000..1b83f5bb --- /dev/null +++ b/src/main/java/model/player/Player.java @@ -0,0 +1,17 @@ +package model.player; + +import java.util.List; + +import model.game.GameNumber; + +public class Player { + private final GameNumber gameNumber; + + public Player(String number) { + this.gameNumber = new GameNumber(number); + } + + public List getNumber() { + return gameNumber.getNumber(); + } +} diff --git a/src/main/java/view/InputView.java b/src/main/java/view/InputView.java new file mode 100644 index 00000000..5b31b1b2 --- /dev/null +++ b/src/main/java/view/InputView.java @@ -0,0 +1,19 @@ +package view; + +import java.util.Scanner; + +public class InputView { + private static final String INPUT_NUMBER_MESSAGE = "숫자를 입력해주세요: "; + private static final String RESTART_OR_QUIT_MESSAGE = "게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요."; + private static final Scanner SCANNER = new Scanner(System.in); + + public String readPlayerNumber() { + System.out.print(INPUT_NUMBER_MESSAGE); + return SCANNER.nextLine(); + } + + public String readRestartOrQuit() { + System.out.println(RESTART_OR_QUIT_MESSAGE); + return SCANNER.nextLine(); + } +} diff --git a/src/main/java/view/OutputView.java b/src/main/java/view/OutputView.java new file mode 100644 index 00000000..7e98f831 --- /dev/null +++ b/src/main/java/view/OutputView.java @@ -0,0 +1,24 @@ +package view; + +import model.game.Result; + +public class OutputView { + private static final String ERROR_PREFIX = "[ERROR]"; + private static final String GAME_CLEAR_MESSAGE = "3개의 숫자를 모두 맞히셨습니다! 게임 끝"; + + public void printResult(Result result) { + System.out.println(result); + } + + public void printGameClearMessage() { + System.out.println(GAME_CLEAR_MESSAGE); + } + + public void printErrorMessage(String message) { + if (message != null && message.startsWith(ERROR_PREFIX)) { + System.out.println(message); + return; + } + System.out.println(ERROR_PREFIX + " " + (message == null ? "잘못된 입력입니다." : message)); + } +} diff --git a/src/test/java/.gitkeep b/src/test/java/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/test/java/model/computer/ComputerTest.java b/src/test/java/model/computer/ComputerTest.java new file mode 100644 index 00000000..0a358dc8 --- /dev/null +++ b/src/test/java/model/computer/ComputerTest.java @@ -0,0 +1,57 @@ +package model.computer; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import model.game.Result; +import model.generator.TestNumberGenerator; + +public class ComputerTest { + @Test + @DisplayName("정답 123, 입력 123이면 3스트라이크 0볼") + void calculateAllStrike() { + Computer computer = new Computer(new TestNumberGenerator(List.of(1, 2, 3))); + + Result result = computer.calculate(List.of(1, 2, 3)); + + assertEquals(3, result.strikes()); + assertEquals(0, result.balls()); + } + + @Test + @DisplayName("정답 123, 입력 415이면 1볼") + void calculateOneBall() { + Computer computer = new Computer(new TestNumberGenerator(List.of(1, 2, 3))); + + Result result = computer.calculate(List.of(4, 1, 5)); + + assertEquals(1, result.balls()); + assertEquals(0, result.strikes()); + } + + @Test + @DisplayName("정답 123, 입력 132이면 1스트라이크 2볼") + void calculateStrikeAndBalls() { + Computer computer = new Computer(new TestNumberGenerator(List.of(1, 2, 3))); + + Result result = computer.calculate(List.of(1, 3, 2)); + + assertEquals(1, result.strikes()); + assertEquals(2, result.balls()); + } + + @Test + @DisplayName("정답 123, 입력 456이면 0스트라이크 0볼") + void calculateNothing() { + Computer computer = new Computer(new TestNumberGenerator(List.of(1, 2, 3))); + + Result result = computer.calculate(List.of(4, 5, 6)); + + assertEquals(0, result.strikes()); + assertEquals(0, result.balls()); + } +} diff --git a/src/test/java/model/game/GameCommandTest.java b/src/test/java/model/game/GameCommandTest.java new file mode 100644 index 00000000..ea230c68 --- /dev/null +++ b/src/test/java/model/game/GameCommandTest.java @@ -0,0 +1,32 @@ +package model.game; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class GameCommandTest { + @Test + @DisplayName("입력이 1이면 RESTART를 반환한다") + void fromRestart() { + GameCommand command = GameCommand.from("1"); + + assertEquals(GameCommand.RESTART, command); + } + + @Test + @DisplayName("입력이 2이면 QUIT를 반환한다") + void fromQuit() { + GameCommand command = GameCommand.from("2"); + + assertEquals(GameCommand.QUIT, command); + } + + @Test + @DisplayName("1, 2가 아닌 입력이면 예외를 던진다") + void fromInvalidInput() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> GameCommand.from("3")); + + assertEquals("1 또는 2를 입력해야 합니다.", exception.getMessage()); + } +} diff --git a/src/test/java/model/game/GameNumberTest.java b/src/test/java/model/game/GameNumberTest.java new file mode 100644 index 00000000..4122e24b --- /dev/null +++ b/src/test/java/model/game/GameNumberTest.java @@ -0,0 +1,97 @@ +package model.game; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class GameNumberTest { + @Test + @DisplayName("문자열 생성: 정상 입력이면 3자리 숫자로 생성된다") + void createFromStringSuccess() { + GameNumber gameNumber = new GameNumber("123"); + + assertEquals(List.of(1, 2, 3), gameNumber.getNumber()); + } + + @Test + @DisplayName("리스트 생성: 정상 입력이면 3자리 숫자로 생성된다") + void createFromListSuccess() { + GameNumber gameNumber = new GameNumber(List.of(4, 5, 6)); + + assertEquals(List.of(4, 5, 6), gameNumber.getNumber()); + } + + @Test + @DisplayName("문자열 생성: null이면 예외가 발생한다") + void createFromNull() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new GameNumber((String)null)); + + assertEquals("[ERROR] 숫자는 3자리여야 합니다.", exception.getMessage()); + } + + @Test + @DisplayName("문자열 생성: 길이가 3이 아니면 예외가 발생한다") + void createFromStringWithInvalidLength() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> new GameNumber("12")); + + assertEquals("[ERROR] 숫자는 3자리여야 합니다.", exception.getMessage()); + } + + @Test + @DisplayName("리스트 생성: 길이가 3이 아니면 예외가 발생한다") + void createFromListWithInvalidLength() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new GameNumber(List.of(1, 2))); + + assertEquals("[ERROR] 숫자는 3자리여야 합니다.", exception.getMessage()); + } + + @Test + @DisplayName("문자열 생성: 숫자가 아니면 예외가 발생한다") + void createFromStringWithNotDigit() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new GameNumber("12a")); + + assertEquals("[ERROR] 숫자만 입력해야 합니다.", exception.getMessage()); + } + + @Test + @DisplayName("문자열 생성: 0이 포함되면 예외가 발생한다") + void createFromStringWithOutOfRange() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new GameNumber("102")); + + assertEquals("[ERROR] 숫자는 1부터 9까지만 가능합니다.", exception.getMessage()); + } + + @Test + @DisplayName("리스트 생성: 10이 포함되면 예외가 발생한다") + void createFromListWithOutOfRange() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new GameNumber(List.of(1, 2, 10))); + + assertEquals("[ERROR] 숫자는 1부터 9까지만 가능합니다.", exception.getMessage()); + } + + @Test + @DisplayName("문자열 생성: 중복이 있으면 예외가 발생한다") + void createFromStringWithDuplicated() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new GameNumber("112")); + + assertEquals("[ERROR] 숫자는 서로 중복될 수 없습니다.", exception.getMessage()); + } + + @Test + @DisplayName("리스트 생성: 중복이 있으면 예외가 발생한다") + void createFromListWithDuplicated() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new GameNumber(List.of(7, 7, 1))); + + assertEquals("[ERROR] 숫자는 서로 중복될 수 없습니다.", exception.getMessage()); + } +} diff --git a/src/test/java/model/game/ResultTest.java b/src/test/java/model/game/ResultTest.java new file mode 100644 index 00000000..87a76a85 --- /dev/null +++ b/src/test/java/model/game/ResultTest.java @@ -0,0 +1,44 @@ +package model.game; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class ResultTest { + @Test + @DisplayName("스트라이크와 볼이 모두 0이면 낫싱이다") + void nothing() { + Result result = new Result(0, 0); + + assertTrue(result.isNothing()); + assertEquals("낫싱", result.toString()); + } + + @Test + @DisplayName("스트라이크만 있으면 스트라이크만 출력한다") + void onlyStrike() { + Result result = new Result(2, 0); + + assertFalse(result.isNothing()); + assertEquals("2스트라이크", result.toString()); + } + + @Test + @DisplayName("볼만 있으면 볼만 출력한다") + void onlyBall() { + Result result = new Result(0, 1); + + assertFalse(result.isNothing()); + assertEquals("1볼", result.toString()); + } + + @Test + @DisplayName("스트라이크와 볼이 모두 있으면 공백으로 구분해서 출력한다") + void strikeAndBall() { + Result result = new Result(1, 2); + + assertFalse(result.isNothing()); + assertEquals("1스트라이크 2볼", result.toString()); + } +} diff --git a/src/test/java/model/generator/RandomNumberGeneratorTest.java b/src/test/java/model/generator/RandomNumberGeneratorTest.java new file mode 100644 index 00000000..589e3a5b --- /dev/null +++ b/src/test/java/model/generator/RandomNumberGeneratorTest.java @@ -0,0 +1,39 @@ +package model.generator; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class RandomNumberGeneratorTest { + private static final int TEST_COUNT = 100; + private static final int NUMBER_LENGTH = 3; + private static final int MIN_DIGIT = 1; + private static final int MAX_DIGIT = 9; + + @Test + @DisplayName("랜덤 숫자 생성은 항상 3자리, 1~9 범위, 중복 없는 숫자를 생성한다") + void generateValidRandomNumbers() { + RandomNumberGenerator generator = new RandomNumberGenerator(); + + for (int i = 0; i < TEST_COUNT; i++) { + List numbers = generator.generate(); + + // 길이 검증 + assertEquals(NUMBER_LENGTH, numbers.size()); + + // 범위 검증 + for (int num : numbers) { + assertTrue(num >= MIN_DIGIT && num <= MAX_DIGIT); + } + + // 중복 검증 + Set uniqueNumbers = new HashSet<>(numbers); + assertEquals(NUMBER_LENGTH, uniqueNumbers.size()); + } + } +} diff --git a/src/test/java/model/generator/TestNumberGenerator.java b/src/test/java/model/generator/TestNumberGenerator.java new file mode 100644 index 00000000..01321f31 --- /dev/null +++ b/src/test/java/model/generator/TestNumberGenerator.java @@ -0,0 +1,16 @@ +package model.generator; + +import java.util.List; + +public class TestNumberGenerator implements NumberGenerator { + private final List fixed; + + public TestNumberGenerator(List fixed) { + this.fixed = fixed; + } + + @Override + public List generate() { + return fixed; + } +}