diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml new file mode 100644 index 00000000..caa816cf --- /dev/null +++ b/.github/workflows/coverage.yaml @@ -0,0 +1,93 @@ +name: Update Test Coverage + +on: + push: + pull_request: + +jobs: + python: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.12" + activate-environment: 'true' + enable-cache: true + + - name: Install dependencies + run: | + uv sync --all-extras + uv pip install coverage + + - name: Run Tests with Coverage + run: coverage run -m pytest + + - name: Generate XML Coverage + run: coverage xml + + - name: Upload Python coverage artifact + uses: actions/upload-artifact@v7 + with: + name: python-coverage + path: coverage.xml + + java: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + + - name: Run Java Tests with Coverage + run: mvn test + + - name: Upload Java coverage artifact + uses: actions/upload-artifact@v7 + with: + name: java-coverage + path: target/site/jacoco/jacoco.xml + + upload: + runs-on: ubuntu-latest + needs: [ python, java ] + + steps: + - name: Download Python coverage + uses: actions/download-artifact@v7 + with: + name: python-coverage + + - name: Download Java coverage + uses: actions/download-artifact@v7 + with: + name: java-coverage + path: jacoco + + - name: Upload to Coveralls + uses: coverallsapp/github-action@v2 + with: + files: coverage.xml jacoco/jacoco.xml + format: cobertura + + - name: Upload Python report to Codacy + uses: codacy/codacy-coverage-reporter-action@v1.3.0 + with: + project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} + coverage-reports: coverage.xml + + - name: Upload Java report to Codacy + uses: codacy/codacy-coverage-reporter-action@v1.3.0 + with: + project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} + coverage-reports: jacoco/jacoco.xml diff --git a/.github/workflows/release_pypi.yaml b/.github/workflows/release_pypi.yaml index 63ff5b7c..a358c0eb 100644 --- a/.github/workflows/release_pypi.yaml +++ b/.github/workflows/release_pypi.yaml @@ -1,8 +1,6 @@ name: Upload Python Package on: - release: - types: [published] workflow_dispatch: permissions: @@ -15,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@v6 - name: Install uv (faster alternative to pip) uses: astral-sh/setup-uv@v7 diff --git a/CLAUDE.md b/CLAUDE.md index 20adbd3d..1cf14280 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,15 +2,15 @@ Mastermind solver using Java algorithms (performance) + Python UI (terminal). Goal: Efficiently solve c=9, d=9 cases. -Status: MVP shipped to PyPI. Now improving the Python UI. +Status: Feature complete and shippable. Future edits will likely be small improvements. ### Code Organization - **Java algorithm**: `./src/main/java/org/mastermind/` -- **Java tests**: `./src/tests/java/org/mastermind/` (JUnit 5) +- **Java tests**: `./src/test/java/org/mastermind/` (JUnit 5) - **Java benchmarks**: `./src/benchmarks/java/org/mastermind/` (JMH) - **Python program**: `./src/main/python/mastermind/` (entry point: `main.py`) -- **Python tests**: `./src/tests/python/mastermind/` (pytest) +- **Python tests**: `./src/test/python/mastermind/` (pytest) - **Build**: `make build-java` → `target/mastermind-solver.jar` ### Algorithm Flow @@ -43,7 +43,7 @@ Entry: `main.py` → `java_setup.ensure_ready()` → `welcome.welcome()` (main m ### Current Focus -- Improve Python UI using rich. +- No particular focus. ### Preference diff --git a/README.md b/README.md index d9006593..affde5f9 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@ + +

Mastermind Logo

-| **Version:** | [![GitHub tag](https://img.shields.io/github/tag/FlysonBot/Mastermind?include_prereleases=&sort=semver&color=blue)](https://github.com/FlysonBot/Mastermind/tags) [![GitHub Release](https://img.shields.io/github/v/release/FlysonBot/Mastermind?include_prereleases)](https://github.com/FlysonBot/Mastermind/releases) [![Python Version](https://img.shields.io/pypi/pyversions/mastermind-ai)](https://www.python.org/downloads/) [![PyPI - Version](https://img.shields.io/pypi/v/mastermind-ai)](https://pypi.org/project/mastermind-ai/) | + +| **Testing:** | [![Testing Status](https://img.shields.io/github/actions/workflow/status/FlysonBot/Mastermind/coverage.yaml?label=test)](https://github.com/FlysonBot/Mastermind/actions/workflows/coverage.yaml) [![Test Coverage](https://coveralls.io/repos/github/FlysonBot/Mastermind/badge.svg?branch=main)](https://coveralls.io/github/FlysonBot/Mastermind?branch=main) [![CodeFactor](https://www.codefactor.io/repository/github/flysonbot/mastermind/badge/main)](https://www.codefactor.io/repository/github/flysonbot/mastermind/overview/main) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/af7ee6c4fbc945f88a41ef8edbea682d)](https://app.codacy.com/gh/FlysonBot/Mastermind/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)| | --- | :-: | +| **Version:** | [![GitHub tag](https://img.shields.io/github/tag/FlysonBot/Mastermind?include_prereleases=&sort=semver&color=blue)](https://github.com/FlysonBot/Mastermind/tags) [![GitHub Release](https://img.shields.io/github/v/release/FlysonBot/Mastermind?include_prereleases)](https://github.com/FlysonBot/Mastermind/releases) [![Python Version](https://img.shields.io/pypi/pyversions/mastermind-ai)](https://www.python.org/downloads/) [![PyPI - Version](https://img.shields.io/pypi/v/mastermind-ai)](https://pypi.org/project/mastermind-ai/) | | **Meta:** | [![GitHub License](https://img.shields.io/github/license/FlysonBot/Mastermind)](https://github.com/FlysonBot/Mastermind/blob/main/LICENSE) ![PyPI Status](https://img.shields.io/pypi/status/mastermind-ai) ![Repo Size](https://img.shields.io/github/repo-size/FlysonBot/Mastermind) [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/FlysonBot/Mastermind/main.svg)](https://results.pre-commit.ci/latest/github/FlysonBot/Mastermind/main) | + + **Links:** - [Documentation](https://flysonbot.github.io/Mastermind/) diff --git a/pom.xml b/pom.xml index c46e4f21..27c0bafd 100644 --- a/pom.xml +++ b/pom.xml @@ -51,6 +51,25 @@ mastermind-solver + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + + prepare-agent + + + + report + test + + report + + + + diff --git a/pyproject.toml b/pyproject.toml index 3bbe5279..fc9faab5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mastermind-ai" -version = "2.1.0b0" +version = "2.2.0" requires-python = ">=3.12" authors = [{ name = "FlysonBot" }] @@ -15,7 +15,7 @@ readme = "README.md" # Tags keywords = ["mastermind", "game", "puzzle"] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: End Users/Desktop", "Intended Audience :: Education", @@ -41,6 +41,15 @@ Issues = "https://github.com/FlysonBot/Mastermind/issues" [tool.setuptools.packages.find] where = ["src/main/python"] +[tool.pytest.ini_options] +pythonpath = ["src/main/python"] +testpaths = ["src/test/python"] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", +] + [tool.setuptools] package-data = { mastermind = ["*.jar", "jre/*.zip"] } diff --git a/src/main/python/mastermind/gamemode/assisted.py b/src/main/python/mastermind/gamemode/assisted.py index 797fd67e..391db004 100644 --- a/src/main/python/mastermind/gamemode/assisted.py +++ b/src/main/python/mastermind/gamemode/assisted.py @@ -4,24 +4,20 @@ from mastermind.jvm import MastermindSession from mastermind.ui import console, pause from mastermind.ui.convert_code import display, parse_code -from rich.prompt import Prompt +from mastermind.ui.prompts import ask_game_settings +from rich.prompt import IntPrompt, Prompt from rich.rule import Rule -C = 6 -D = 4 -MAX_TRIES = 10 - -def _parse_feedback(raw: str) -> int | None: +def _parse_feedback(raw: str, d: int) -> int | None: """Parse 'XbYw' or 'X Y' into feedback int (black*10 + white).""" raw = raw.strip().lower().replace(" ", "") - # Accept formats: "2b1w", "21", "2b1", "2 1" m = re.fullmatch(r"(\d)b?(\d)w?", raw) if not m: return None black, white = int(m.group(1)), int(m.group(2)) - if black + white > D or black < 0 or white < 0: + if black + white > d or black < 0 or white < 0: return None return black * 10 + white @@ -30,58 +26,94 @@ def _parse_feedback(raw: str) -> int | None: def play(): console.print() console.print(Rule("[bold]Mastermind (Assist)[/bold]")) - console.print(Rule(f"[dim]c={C} d={D} tries={MAX_TRIES}[/dim]", style="dim")) console.print() + + c, d, max_tries = ask_game_settings() + console.print(Rule(f"[dim]c={c} d={d} tries={max_tries}[/dim]", style="dim")) + console.print() + console.print("I'll suggest the best guess each turn.") console.print( "Enter the guess you actually played (or press Enter to use my suggestion)," ) - console.print("then enter the feedback you received.\n") - - session = MastermindSession(C, D) + console.print("then enter the feedback you received.") + console.print("[dim]Enter 'u' at any prompt to undo.[/dim]\n") + + session = MastermindSession(c, d) + prev_remaining: list[int] = [ + c**d + ] # index 0 = before turn 1, index i = after turn i + suggestions: dict[int, tuple[int, str]] = {} # attempt -> (ind, str) + + attempt = 1 + while attempt <= max_tries: + # Get or compute suggestion for this attempt + if attempt not in suggestions: + suggestion_ind = int(session.suggestGuess()) + suggestion_str = display(suggestion_ind, c, d) + suggestions[attempt] = (suggestion_ind, suggestion_str) + else: + suggestion_ind, suggestion_str = suggestions[attempt] - for attempt in range(1, MAX_TRIES + 1): - suggestion_ind = int(session.suggestGuess()) - suggestion_str = display(suggestion_ind, C, D) console.print( - f"\n▸ Turn {attempt}/{MAX_TRIES} — 💡 Suggested guess: [cyan]{suggestion_str}[/cyan]" + f"\n▸ Turn {attempt}/{max_tries} — 💡 Suggested guess: [cyan]{suggestion_str}[/cyan]" ) # Ask what guess was actually played + guess_ind = None while True: raw = Prompt.ask( " Your guess", default=suggestion_str, console=console, ) + if raw.strip().lower() == "u": + if attempt == 1: + console.print(" [yellow]Nothing to undo.[/yellow]") + continue + session.undo(1) + suggestions.pop(attempt, None) + attempt -= 1 + prev_remaining.pop() + break if raw == suggestion_str: guess_ind = suggestion_ind break - - guess_ind = parse_code(raw, C, D) + guess_ind = parse_code(raw, c, d) if guess_ind is not None: break - console.print( - f" [red]! Invalid. Use exactly {D} digits, each between 1 and {C}.[/red]" + f" [red]! Invalid. Use exactly {d} digits, each between 1 and {c}.[/red]" ) + if guess_ind is None: + # Rewound via 'u' at guess prompt + continue + # Ask for feedback + feedback = None while True: raw = Prompt.ask( " Feedback (blacks whites, e.g. '2b1w' or '2 1')", console=console ) - feedback = _parse_feedback(raw) + if raw.strip().lower() == "u": + # Messed up the guess this turn — re-ask without undoing session + console.print(" [yellow]Re-enter your guess.[/yellow]") + break + feedback = _parse_feedback(raw, d) if feedback is not None: break - console.print( " [red]! Invalid. Enter blacks and whites, e.g. '2b1w', '21', or '2 1'.[/red]" ) + if feedback is None: + # 'u' at feedback prompt — re-ask guess for same attempt + continue + black = feedback // 10 - if black == D: + if black == d: session.recordGuess(guess_ind, feedback) console.print( f"\n[bold green]✓ Perfect! Solved in {attempt} {'tries' if attempt != 1 else 'try'}![/bold green]\n" @@ -93,23 +125,45 @@ def play(): session.recordGuess(guess_ind, feedback) except jpype.JException as e: - if "No valid secrets remain" in str(e): - console.print( - "\n[red]✗ No valid codes match the feedback history — your inputs may be inconsistent.[/red]" - ) - console.print( - "Please double-check your guesses and feedback, then start over.\n" - ) - else: + if "No valid secrets remain" not in str(e): raise - - pause() - return + attempt += 1 + console.print( + "\n[red]✗ No valid codes match the feedback history — your inputs may be inconsistent.[/red]" + ) + raw = Prompt.ask( + " Undo some guesses?", + choices=["y", "n"], + default="y", + console=console, + ) + if raw == "n": + console.print() + pause() + return + while True: + n = IntPrompt.ask( + f" How many guesses to undo (1–{attempt - 1})", + console=console, + ) + if 1 <= n <= attempt - 1: + break + console.print(f" [red]! Must be between 1 and {attempt - 1}.[/red]") + for t in range(attempt - n, attempt): + suggestions.pop(t, None) + session.undo(n) + attempt -= n + del prev_remaining[attempt:] + continue remaining = session.getSolutionSpaceSize() + eliminated = round((1 - remaining / prev_remaining[-1]) * 100) + prev_remaining.append(remaining) console.print( - f" [dim]({remaining} possible code{'s' if remaining != 1 else ''} remaining)[/dim]\n" + f" [dim](eliminated {eliminated}%" + f", {remaining} possible code{'s' if remaining != 1 else ''} remaining)[/dim]\n" ) + attempt += 1 console.print( "\n[red]✗ Out of turns. The algorithm could not determine the code — try again from the start.[/red]\n" diff --git a/src/main/python/mastermind/gamemode/computer.py b/src/main/python/mastermind/gamemode/computer.py index c3149fd4..8e135f54 100644 --- a/src/main/python/mastermind/gamemode/computer.py +++ b/src/main/python/mastermind/gamemode/computer.py @@ -1,66 +1,44 @@ -import random - from jpype.types import JInt from mastermind.jvm import Feedback, MastermindSession from mastermind.ui import console, pause -from mastermind.ui.convert_code import display, parse_code -from rich.prompt import Prompt +from mastermind.ui.convert_code import display +from mastermind.ui.prompts import ask_game_settings, ask_secret from rich.rule import Rule -C = 6 -D = 4 -MAX_TRIES = 10 - def play(): console.print() console.print(Rule("[bold]Mastermind (Watch)[/bold]")) - console.print(Rule(f"[dim]c={C} d={D} tries={MAX_TRIES}[/dim]", style="dim")) console.print() - choice = Prompt.ask( - "Who sets the secret code?\n [bold]1)[/bold] I (computer)\n [bold]2)[/bold] You\n", - choices=["1", "2"], - show_choices=False, - default="1", - console=console, - ) - - if choice == "2": - while True: - raw = Prompt.ask( - f"Enter your secret code ([cyan]{D} digits, each 1–{C}[/cyan])", - console=console, - ) - secret_ind = parse_code(raw, C, D) - if secret_ind is not None: - break - console.print( - f" [red]! Invalid. Use exactly {D} digits, each between 1 and {C}.[/red]" - ) - console.print("\n[green]Code set.[/green] Watch the computer solve it!\n") + c, d, max_tries = ask_game_settings() + console.print(Rule(f"[dim]c={c} d={d} tries={max_tries}[/dim]", style="dim")) + console.print() - else: - secret_ind = random.randrange(C**D) - console.print("\nI have set a secret code. Now I will solve it...\n") + secret_ind = ask_secret( + c, + d, + human_follow_up="\n[green]Code set.[/green] Watch the computer solve it!\n", + computer_follow_up="\nI have set a secret code. Now I will solve it...\n", + ) - session = MastermindSession(C, D) - color_freq: list[int] = JInt[C] + session = MastermindSession(c, d) + color_freq: list[int] = JInt[c] - for attempt in range(1, MAX_TRIES + 1): + for attempt in range(1, max_tries + 1): guess_ind = int(session.suggestGuess()) - feedback = int(Feedback.getFeedback(guess_ind, secret_ind, C, D, color_freq)) + feedback = int(Feedback.getFeedback(guess_ind, secret_ind, c, d, color_freq)) black = feedback // 10 white = feedback % 10 console.print( - f" ▸ Guess {attempt}/{MAX_TRIES}: [cyan]{display(guess_ind, C, D)}[/cyan]" + f" ▸ Guess {attempt}/{max_tries}: [cyan]{display(guess_ind, c, d)}[/cyan]" f" → [bold]{black} black[/bold], {white} white" ) session.recordGuess(guess_ind, feedback) - if black == D: + if black == d: console.print( f"\n[bold green]✓ I solved it in {attempt} {'tries' if attempt != 1 else 'try'}![/bold green]\n" ) @@ -68,8 +46,8 @@ def play(): return console.print( - f"\n[red]✗ I failed to solve it within {MAX_TRIES} tries.[/red]" - f" The secret was: [cyan]{display(secret_ind, C, D)}[/cyan]\n" + f"\n[red]✗ I failed to solve it within {max_tries} tries.[/red]" + f" The secret was: [cyan]{display(secret_ind, c, d)}[/cyan]\n" ) pause() diff --git a/src/main/python/mastermind/gamemode/human.py b/src/main/python/mastermind/gamemode/human.py index ed764483..8b965163 100644 --- a/src/main/python/mastermind/gamemode/human.py +++ b/src/main/python/mastermind/gamemode/human.py @@ -1,71 +1,49 @@ -import random - from jpype.types import JInt from mastermind.jvm import Feedback from mastermind.ui import console, pause from mastermind.ui.convert_code import display, parse_code +from mastermind.ui.prompts import ask_game_settings, ask_secret from rich.prompt import Prompt from rich.rule import Rule -C = 6 -D = 4 -MAX_TRIES = 10 - def play(): console.print() console.print(Rule("[bold]Mastermind (Play)[/bold]")) - console.print(Rule(f"[dim]c={C} d={D} tries={MAX_TRIES}[/dim]", style="dim")) console.print() - choice = Prompt.ask( - "Who sets the secret code?\n [bold]1)[/bold] I (computer)\n [bold]2)[/bold] You (playing with someone else)\n", - choices=["1", "2"], - show_choices=False, - default="1", - console=console, - ) - - if choice == "2": - while True: - raw = Prompt.ask( - f"Enter your secret code ([cyan]{D} digits, each 1–{C}[/cyan])", - console=console, - ) - secret_ind = parse_code(raw, C, D) - if secret_ind is not None: - break - console.print( - f" [red]! Invalid. Use exactly {D} digits, each between 1 and {C}.[/red]" - ) - console.print("\n[green]Code set.[/green] Hand the keyboard to the guesser!\n") + c, d, max_tries = ask_game_settings() + console.print(Rule(f"[dim]c={c} d={d} tries={max_tries}[/dim]", style="dim")) + console.print() - else: - total_codes = C**D - secret_ind = random.randrange(total_codes) - console.print("\nI have set a secret code. Go ahead and guess it.\n") + secret_ind = ask_secret( + c, + d, + human_follow_up="\n[green]Code set.[/green] Hand the keyboard to the guesser!\n", + computer_follow_up="\nI have set a secret code. Go ahead and guess it.\n", + ) - color_freq: list[int] = JInt[C] + color_freq: list[int] = JInt[c] won = False attempt = 0 - for attempt in range(1, MAX_TRIES + 1): + for attempt in range(1, max_tries + 1): while True: - raw = Prompt.ask(f"▸ Guess {attempt}/{MAX_TRIES}", console=console) - guess_ind = parse_code(raw, C, D) + raw = Prompt.ask(f"▸ Guess {attempt}/{max_tries}", console=console) + guess_ind = parse_code(raw, c, d) if guess_ind is not None: break console.print( - f" [red]! Invalid. Use exactly {D} digits, each between 1 and {C}.[/red]" + f" [red]! Invalid. Use exactly {d} digits, each between 1 and {c}.[/red]" ) - feedback = int(Feedback.getFeedback(guess_ind, secret_ind, C, D, color_freq)) + feedback = int(Feedback.getFeedback(guess_ind, secret_ind, c, d, color_freq)) black = feedback // 10 white = feedback % 10 console.print(f" Feedback: [bold]{black} black[/bold], {white} white\n") - if black == D: + if black == d: won = True break @@ -75,7 +53,7 @@ def play(): ) else: console.print( - f"[red]✗ Out of tries![/red] The secret code was: [cyan]{display(secret_ind, C, D)}[/cyan]\n" + f"[red]✗ Out of tries![/red] The secret code was: [cyan]{display(secret_ind, c, d)}[/cyan]\n" ) pause() diff --git a/src/main/python/mastermind/ui/prompts.py b/src/main/python/mastermind/ui/prompts.py new file mode 100644 index 00000000..59a02b89 --- /dev/null +++ b/src/main/python/mastermind/ui/prompts.py @@ -0,0 +1,65 @@ +import random + +from mastermind.ui.console import console +from mastermind.ui.convert_code import parse_code +from rich.prompt import IntPrompt, Prompt + + +def ask_game_settings() -> tuple[int, int, int]: + """Ask for colors, digits, and max tries. Returns (c, d, max_tries).""" + + while True: + c = IntPrompt.ask(" Colors (1–9)", default=6, console=console) + if 1 <= c <= 9: + break + console.print(" [red]! Must be between 1 and 9.[/red]") + + while True: + d = IntPrompt.ask(" Digits (1–9)", default=4, console=console) + if 1 <= d <= 9: + break + console.print(" [red]! Must be between 1 and 9.[/red]") + + while True: + max_tries = IntPrompt.ask(" Max tries", default=10, console=console) + if max_tries >= 1: + break + console.print(" [red]! Must be at least 1.[/red]") + + return c, d, max_tries + + +def ask_secret(c: int, d: int, human_follow_up: str, computer_follow_up: str) -> int: + """ + Ask who sets the secret code and return the secret index. + + human_follow_up — message printed after the human enters a valid code + computer_follow_up — message printed after the computer picks a code + """ + choice = Prompt.ask( + "Who sets the secret code?\n [bold]1)[/bold] Computer\n [bold]2)[/bold] Human\n", + choices=["1", "2"], + show_choices=False, + default="1", + console=console, + ) + + if choice == "2": + while True: + raw = Prompt.ask( + f"Enter your secret code ([cyan]{d} digits, each 1–{c}[/cyan], hidden)", + password=True, + console=console, + ) + secret_ind = parse_code(raw, c, d) + if secret_ind is not None: + break + console.print( + f" [red]! Invalid. Use exactly {d} digits, each between 1 and {c}.[/red]" + ) + console.print(human_follow_up) + else: + secret_ind = random.randrange(c**d) + console.print(computer_follow_up) + + return secret_ind diff --git a/src/main/python/mastermind/ui/rules.py b/src/main/python/mastermind/ui/rules.py index 27678d36..b0ae78ca 100644 --- a/src/main/python/mastermind/ui/rules.py +++ b/src/main/python/mastermind/ui/rules.py @@ -15,24 +15,24 @@ def show_rules(): "\n [red]code-setter[/red] and a [red]code-breaker[/red]." "\n" "\n ▸ The [red]code-setter[/red] picks a secret code — a sequence of" - "\n colored pegs (here represented as digits 1–6)." + "\n colored pegs (here represented as digits)." "\n ▸ The [red]code-breaker[/red] tries to guess it in as few attempts" "\n as possible, using feedback after each guess." "\n" ), Rule("[bold]THE CODE[/bold]", style="dim"), ( - "\n A code is a sequence of 4 digits, each between 1 and 6." + "\n A code is a sequence of digits, each between 1 and the number of colors." "\n Repetition is allowed." "\n" - "\n Examples of valid codes: [cyan]1234 6611 3333 2416[/cyan]" + "\n Examples of valid codes (c=6, d=4): [cyan]1234 6611 3333 2416[/cyan]" "\n" ), Rule("[bold]THE GOAL[/bold]", style="dim"), ( "\n The [red]code-breaker[/red] wins by guessing the exact secret code" - "\n within 10 tries. If they run out of tries, the [red]code-setter[/red]" - "\n wins and the secret is revealed." + "\n within the allowed number of tries. If they run out, the" + "\n [red]code-setter[/red] wins and the secret is revealed." "\n" ), Rule("[bold]FEEDBACK: BLACKS AND WHITES[/bold]", style="dim"), @@ -99,14 +99,14 @@ def show_rules(): "\n digits appear in the secret at all — very useful!" "\n ▸ Use early guesses to test many different digits at once." "\n ▸ Narrow down positions with follow-up guesses based on the" - "\n white clues you receive." + "\n black and white clues you receive." "\n" ), Rule("[bold]WINNING AND LOSING[/bold]", style="dim"), ( - "\n ▸ 4 black, 0 white = perfect guess → [green]you win![/green]" - "\n ▸ Guess correctly within 10 tries → [red]code-breaker[/red] wins." - "\n ▸ Fail to guess within 10 tries → [red]code-setter[/red] wins," + "\n ▸ All black, 0 white = perfect guess → [green]you win![/green]" + "\n ▸ Guess correctly within the try limit → [red]code-breaker[/red] wins." + "\n ▸ Fail to guess in time → [red]code-setter[/red] wins," "\n and the secret is revealed." "\n" ), diff --git a/src/tests/java/org/mastermind/MastermindSessionTest.java b/src/test/java/org/mastermind/MastermindSessionTest.java similarity index 100% rename from src/tests/java/org/mastermind/MastermindSessionTest.java rename to src/test/java/org/mastermind/MastermindSessionTest.java diff --git a/src/tests/java/org/mastermind/codes/CanonicalCodeTest.java b/src/test/java/org/mastermind/codes/CanonicalCodeTest.java similarity index 100% rename from src/tests/java/org/mastermind/codes/CanonicalCodeTest.java rename to src/test/java/org/mastermind/codes/CanonicalCodeTest.java diff --git a/src/tests/java/org/mastermind/codes/ConvertCodeTest.java b/src/test/java/org/mastermind/codes/ConvertCodeTest.java similarity index 100% rename from src/tests/java/org/mastermind/codes/ConvertCodeTest.java rename to src/test/java/org/mastermind/codes/ConvertCodeTest.java diff --git a/src/tests/java/org/mastermind/codes/SampledCodeTest.java b/src/test/java/org/mastermind/codes/SampledCodeTest.java similarity index 100% rename from src/tests/java/org/mastermind/codes/SampledCodeTest.java rename to src/test/java/org/mastermind/codes/SampledCodeTest.java diff --git a/src/tests/java/org/mastermind/compute/ExpectedSizeTest.java b/src/test/java/org/mastermind/compute/ExpectedSizeTest.java similarity index 100% rename from src/tests/java/org/mastermind/compute/ExpectedSizeTest.java rename to src/test/java/org/mastermind/compute/ExpectedSizeTest.java diff --git a/src/tests/java/org/mastermind/compute/FeedbackTest.java b/src/test/java/org/mastermind/compute/FeedbackTest.java similarity index 100% rename from src/tests/java/org/mastermind/compute/FeedbackTest.java rename to src/test/java/org/mastermind/compute/FeedbackTest.java diff --git a/src/tests/java/org/mastermind/compute/SolutionSpaceTest.java b/src/test/java/org/mastermind/compute/SolutionSpaceTest.java similarity index 100% rename from src/tests/java/org/mastermind/compute/SolutionSpaceTest.java rename to src/test/java/org/mastermind/compute/SolutionSpaceTest.java diff --git a/src/tests/java/org/mastermind/solver/BestFirstGuessTest.java b/src/test/java/org/mastermind/solver/BestFirstGuessTest.java similarity index 100% rename from src/tests/java/org/mastermind/solver/BestFirstGuessTest.java rename to src/test/java/org/mastermind/solver/BestFirstGuessTest.java diff --git a/src/tests/java/org/mastermind/solver/BestGuessTest.java b/src/test/java/org/mastermind/solver/BestGuessTest.java similarity index 100% rename from src/tests/java/org/mastermind/solver/BestGuessTest.java rename to src/test/java/org/mastermind/solver/BestGuessTest.java diff --git a/src/test/python/mastermind/conftest.py b/src/test/python/mastermind/conftest.py new file mode 100644 index 00000000..637169a5 --- /dev/null +++ b/src/test/python/mastermind/conftest.py @@ -0,0 +1,7 @@ +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def jvm(): + """Start the JVM once for the entire test session.""" + import mastermind.jvm # noqa: F401 — importing starts the JVM as a side effect diff --git a/src/test/python/mastermind/test_gamemode_assisted.py b/src/test/python/mastermind/test_gamemode_assisted.py new file mode 100644 index 00000000..2c158b6d --- /dev/null +++ b/src/test/python/mastermind/test_gamemode_assisted.py @@ -0,0 +1,485 @@ +"""Tests for assisted.play() — Python-side logic only. + +Mocks: + - ask_game_settings (setup prompt) + - MastermindSession (Java) + - Prompt.ask / IntPrompt.ask (user input) + - parse_code (Java-backed) + - display (Java-backed) + - console.print / pause (output side effects) + +jpype.JException is replaced with a plain exception subclass so we can +raise it from mocks without a running JVM. +""" + +from unittest.mock import MagicMock, patch + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _fb(black, white): + return black * 10 + white + + +class _FakeJException(Exception): + """Stand-in for jpype.JException in tests.""" + + +def _make_session(suggestions, solution_sizes=None): + """ + suggestions: list of ints returned by suggestGuess (in order). + solution_sizes: list of ints returned by getSolutionSpaceSize (in order). + Defaults to [100, 50, 10, ...] (shrinking). + """ + session = MagicMock() + session.suggestGuess.side_effect = [int(s) for s in suggestions] + if solution_sizes is None: + solution_sizes = list(range(100, 0, -10)) + session.getSolutionSpaceSize.side_effect = solution_sizes + return session + + +BASE_PATCHES = dict( + ask_settings="mastermind.gamemode.assisted.ask_game_settings", + SessionClass="mastermind.gamemode.assisted.MastermindSession", + prompt="mastermind.gamemode.assisted.Prompt.ask", + int_prompt="mastermind.gamemode.assisted.IntPrompt.ask", + parse_code="mastermind.gamemode.assisted.parse_code", + display="mastermind.gamemode.assisted.display", + console="mastermind.gamemode.assisted.console", + pause="mastermind.gamemode.assisted.pause", + jpype_exc="mastermind.gamemode.assisted.jpype.JException", +) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestAssistedPlay: + def test_win_using_suggestion(self): + """Accept the suggested guess and receive a perfect score → win.""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]), + patch(BASE_PATCHES["parse_code"]), + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]) as console, + patch(BASE_PATCHES["pause"]) as pause, + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + SessionClass.return_value = _make_session([7]) + # Prompt returns suggestion string → accepted; then feedback = "4b0w" + prompt.side_effect = ["1234", "4b0w"] + + from mastermind.gamemode.assisted import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "Perfect" in printed or "✓" in printed + pause.assert_called_once() + + def test_win_using_custom_guess(self): + """Enter a different code (not suggestion) and win.""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]), + patch(BASE_PATCHES["parse_code"]) as parse_code, + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]) as console, + patch(BASE_PATCHES["pause"]), + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + SessionClass.return_value = _make_session([7]) + # User enters "5678" (custom), then feedback = perfect + prompt.side_effect = ["5678", "4b0w"] + parse_code.return_value = 99 # valid custom guess + + from mastermind.gamemode.assisted import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "Perfect" in printed or "✓" in printed + + def test_invalid_guess_retried(self): + """Invalid guess input loops until a valid one is entered.""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]), + patch(BASE_PATCHES["parse_code"]) as parse_code, + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]) as console, + patch(BASE_PATCHES["pause"]), + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + SessionClass.return_value = _make_session([7]) + # "bad" is not the suggestion, parse_code returns None for it + prompt.side_effect = ["bad", "1234", "4b0w"] + parse_code.side_effect = [None, 42] + + from mastermind.gamemode.assisted import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "Invalid" in printed + + def test_invalid_feedback_retried(self): + """Invalid feedback loops until a valid one is entered.""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]), + patch(BASE_PATCHES["parse_code"]), + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]) as console, + patch(BASE_PATCHES["pause"]), + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + session = _make_session([7], solution_sizes=[100]) + SessionClass.return_value = session + # Guess accepted (suggestion), then bad feedback, then good feedback = win + prompt.side_effect = ["1234", "xyz", "4b0w"] + + from mastermind.gamemode.assisted import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "Invalid" in printed + + def test_u_at_guess_prompt_on_first_turn_does_nothing(self): + """'u' on turn 1 at guess prompt prints a warning and re-prompts.""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]), + patch(BASE_PATCHES["parse_code"]), + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]) as console, + patch(BASE_PATCHES["pause"]), + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + SessionClass.return_value = _make_session([7]) + # 'u' on first turn, then accept suggestion, then win + prompt.side_effect = ["u", "1234", "4b0w"] + + from mastermind.gamemode.assisted import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "Nothing to undo" in printed + + def test_u_at_guess_prompt_undoes_previous_turn(self): + """'u' on turn 2 calls session.undo(1) and goes back to turn 1.""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]), + patch(BASE_PATCHES["parse_code"]), + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]), + patch(BASE_PATCHES["pause"]), + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + session = _make_session([7, 7], solution_sizes=[100, 50]) + SessionClass.return_value = session + # Turn 1: accept suggestion, non-winning feedback + # Turn 2 guess: 'u' → undo + # Turn 1 again: accept suggestion, win + prompt.side_effect = ["1234", "1b0w", "u", "1234", "4b0w"] + + from mastermind.gamemode.assisted import play + + play() + + session.undo.assert_called_once_with(1) + + def test_u_at_feedback_prompt_rerequests_guess(self): + """'u' at feedback prompt re-asks the guess for the same turn (no session undo).""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]), + patch(BASE_PATCHES["parse_code"]), + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]) as console, + patch(BASE_PATCHES["pause"]), + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + session = _make_session([7], solution_sizes=[100]) + SessionClass.return_value = session + # Guess accepted, feedback 'u' → re-ask guess, then accept again and win + prompt.side_effect = ["1234", "u", "1234", "4b0w"] + + from mastermind.gamemode.assisted import play + + play() + + # session.undo should NOT be called (u at feedback, not guess) + session.undo.assert_not_called() + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "Re-enter" in printed + + def test_out_of_turns(self): + """Exhaust max_tries without a win → out-of-turns message.""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]), + patch(BASE_PATCHES["parse_code"]), + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]) as console, + patch(BASE_PATCHES["pause"]) as pause, + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 2) + session = _make_session([7, 8], solution_sizes=[100, 50]) + SessionClass.return_value = session + # 2 turns, each: accept suggestion + non-winning feedback + prompt.side_effect = ["1234", "1b0w", "1234", "0b1w"] + + from mastermind.gamemode.assisted import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "Out of turns" in printed or "✗" in printed + pause.assert_called_once() + + def test_suggestion_cached_on_undo(self): + """After undo, the cached suggestion for that turn is re-used (suggestGuess not called again).""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]), + patch(BASE_PATCHES["parse_code"]), + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]), + patch(BASE_PATCHES["pause"]), + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + session = _make_session([7, 8], solution_sizes=[100, 50]) + SessionClass.return_value = session + # Turn 1: accept + non-winning; Turn 2: undo; Turn 1 again: accept + win + prompt.side_effect = ["1234", "1b0w", "u", "1234", "4b0w"] + + from mastermind.gamemode.assisted import play + + play() + + # suggestGuess called only once for turn 1 (cached on re-visit) + assert session.suggestGuess.call_count == 2 # turn1 + turn2 before undo + + def test_remaining_percentage_printed(self): + """After a non-winning turn, the eliminated % and remaining count are printed.""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]), + patch(BASE_PATCHES["parse_code"]), + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]) as console, + patch(BASE_PATCHES["pause"]), + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + # c**d = 6**4 = 1296, after guess: 648 remain → 50% eliminated + session = _make_session([7, 8], solution_sizes=[648, 1]) + SessionClass.return_value = session + prompt.side_effect = ["1234", "1b0w", "1234", "4b0w"] + + from mastermind.gamemode.assisted import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "eliminated" in printed + assert "remaining" in printed + + def test_inconsistent_feedback_offers_undo(self): + """When session raises 'No valid secrets remain', user is offered undo.""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]) as int_prompt, + patch(BASE_PATCHES["parse_code"]), + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]) as console, + patch(BASE_PATCHES["pause"]), + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + session = MagicMock() + session.suggestGuess.side_effect = [7, 8] + session.getSolutionSpaceSize.return_value = 100 + # First recordGuess raises; second (after undo) is fine and leads to win + exc = _FakeJException("No valid secrets remain") + session.recordGuess.side_effect = [exc, None] + SessionClass.return_value = session + + # Turn 1: accept suggestion, bad feedback (raises) → undo 1 → turn 1 again → win + prompt.side_effect = ["1234", "1b0w", "y", "1234", "4b0w"] + int_prompt.return_value = 1 + + from mastermind.gamemode.assisted import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "inconsistent" in printed.lower() or "No valid" in printed + + def test_inconsistent_feedback_user_declines_undo_exits(self): + """When inconsistency is detected and user says 'n', game exits.""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]), + patch(BASE_PATCHES["parse_code"]), + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]), + patch(BASE_PATCHES["pause"]) as pause, + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + session = MagicMock() + session.suggestGuess.return_value = 7 + exc = _FakeJException("No valid secrets remain") + session.recordGuess.side_effect = exc + SessionClass.return_value = session + + # Turn 1: accept suggestion, bad feedback (raises) → user says "n" → exit + prompt.side_effect = ["1234", "1b0w", "n"] + + from mastermind.gamemode.assisted import play + + play() + + pause.assert_called_once() + + def test_inconsistent_undo_1_resumes_from_turn_1(self): + """After inconsistency on turn 1, undo 1 → session.undo(1) called, resumes from turn 1.""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]) as int_prompt, + patch(BASE_PATCHES["parse_code"]), + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]), + patch(BASE_PATCHES["pause"]), + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + session = MagicMock() + session.suggestGuess.side_effect = [7, 8] + session.getSolutionSpaceSize.return_value = 100 + exc = _FakeJException("No valid secrets remain") + session.recordGuess.side_effect = [exc, None] + SessionClass.return_value = session + + # Turn 1: accept, bad feedback → exception → undo 1 → turn 1 again → win + prompt.side_effect = ["1234", "1b0w", "y", "1234", "4b0w"] + int_prompt.return_value = 1 + + from mastermind.gamemode.assisted import play + + play() + + session.undo.assert_called_once_with(1) + + def test_inconsistent_undo_2_resumes_from_correct_turn(self): + """After inconsistency on turn 2, undo 2 → session.undo(2), resumes from turn 1.""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]) as int_prompt, + patch(BASE_PATCHES["parse_code"]), + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]), + patch(BASE_PATCHES["pause"]), + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + session = MagicMock() + # Turn 1 succeeds, turn 2 raises, then turn 1 again wins + session.suggestGuess.side_effect = [7, 8, 9] + session.getSolutionSpaceSize.side_effect = [50, 50] + exc = _FakeJException("No valid secrets remain") + session.recordGuess.side_effect = [None, exc, None] + SessionClass.return_value = session + + # Turn 1: accept + good feedback + # Turn 2: accept + bad feedback → exception → undo 2 → turn 1 again → win + prompt.side_effect = ["1234", "1b0w", "1234", "0b1w", "y", "1234", "4b0w"] + int_prompt.return_value = 2 + + from mastermind.gamemode.assisted import play + + play() + + session.undo.assert_called_once_with(2) + + def test_inconsistent_invalid_undo_count_reprompted(self): + """Out-of-range undo count is rejected and re-prompted.""" + with ( + patch(BASE_PATCHES["ask_settings"]) as ask_settings, + patch(BASE_PATCHES["SessionClass"]) as SessionClass, + patch(BASE_PATCHES["prompt"]) as prompt, + patch(BASE_PATCHES["int_prompt"]) as int_prompt, + patch(BASE_PATCHES["parse_code"]), + patch(BASE_PATCHES["display"], return_value="1234"), + patch(BASE_PATCHES["console"]) as console, + patch(BASE_PATCHES["pause"]), + patch(BASE_PATCHES["jpype_exc"], _FakeJException), + ): + ask_settings.return_value = (6, 4, 10) + session = MagicMock() + session.suggestGuess.side_effect = [7, 8] + session.getSolutionSpaceSize.return_value = 100 + exc = _FakeJException("No valid secrets remain") + session.recordGuess.side_effect = [exc, None] + SessionClass.return_value = session + + # Turn 1: accept, bad feedback → exception → undo prompt: 0 (invalid), then 1 (valid) → win + prompt.side_effect = ["1234", "1b0w", "y", "1234", "4b0w"] + int_prompt.side_effect = [0, 1] # first out of range, second valid + + from mastermind.gamemode.assisted import play + + play() + + # IntPrompt asked twice (once invalid, once valid) + assert int_prompt.call_count == 2 + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "Must be between" in printed diff --git a/src/test/python/mastermind/test_gamemode_computer.py b/src/test/python/mastermind/test_gamemode_computer.py new file mode 100644 index 00000000..1c89bf78 --- /dev/null +++ b/src/test/python/mastermind/test_gamemode_computer.py @@ -0,0 +1,175 @@ +"""Tests for computer.play() — Python-side logic only. + +Mocks: + - ask_game_settings / ask_secret (setup prompts) + - MastermindSession (Java) + - Feedback (Java class — whole object replaced) + - display (Java-backed) + - console / pause (output side-effects) +""" + +from unittest.mock import MagicMock, patch + + +def _fb(black, white): + return black * 10 + white + + +def _fake_feedback(return_value=None, side_effect=None): + fb = MagicMock() + if side_effect is not None: + fb.getFeedback.side_effect = side_effect + else: + fb.getFeedback.return_value = return_value + return fb + + +def _make_session(suggestions): + session = MagicMock() + session.suggestGuess.side_effect = suggestions + return session + + +class TestComputerPlay: + def test_solves_on_first_guess(self): + with ( + patch("mastermind.gamemode.computer.ask_game_settings") as ask_settings, + patch("mastermind.gamemode.computer.ask_secret") as ask_secret, + patch("mastermind.gamemode.computer.MastermindSession") as SessionClass, + patch("mastermind.gamemode.computer.Feedback", _fake_feedback(_fb(4, 0))), + patch("mastermind.gamemode.computer.display", return_value="1234"), + patch("mastermind.gamemode.computer.console") as console, + patch("mastermind.gamemode.computer.pause") as pause, + ): + ask_settings.return_value = (6, 4, 10) + ask_secret.return_value = 0 + SessionClass.return_value = _make_session([7]) + + from mastermind.gamemode.computer import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "solved" in printed.lower() or "✓" in printed + pause.assert_called_once() + + def test_win_message_singular_try(self): + with ( + patch("mastermind.gamemode.computer.ask_game_settings") as ask_settings, + patch("mastermind.gamemode.computer.ask_secret") as ask_secret, + patch("mastermind.gamemode.computer.MastermindSession") as SessionClass, + patch("mastermind.gamemode.computer.Feedback", _fake_feedback(_fb(4, 0))), + patch("mastermind.gamemode.computer.display", return_value="1234"), + patch("mastermind.gamemode.computer.console") as console, + patch("mastermind.gamemode.computer.pause"), + ): + ask_settings.return_value = (6, 4, 10) + ask_secret.return_value = 0 + SessionClass.return_value = _make_session([7]) + + from mastermind.gamemode.computer import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "try" in printed + + def test_win_message_plural_tries(self): + with ( + patch("mastermind.gamemode.computer.ask_game_settings") as ask_settings, + patch("mastermind.gamemode.computer.ask_secret") as ask_secret, + patch("mastermind.gamemode.computer.MastermindSession") as SessionClass, + patch( + "mastermind.gamemode.computer.Feedback", + _fake_feedback(side_effect=[_fb(0, 0), _fb(4, 0)]), + ), + patch("mastermind.gamemode.computer.display", return_value="1234"), + patch("mastermind.gamemode.computer.console") as console, + patch("mastermind.gamemode.computer.pause"), + ): + ask_settings.return_value = (6, 4, 10) + ask_secret.return_value = 0 + SessionClass.return_value = _make_session([7, 8]) + + from mastermind.gamemode.computer import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "tries" in printed + + def test_records_each_non_winning_guess(self): + with ( + patch("mastermind.gamemode.computer.ask_game_settings") as ask_settings, + patch("mastermind.gamemode.computer.ask_secret") as ask_secret, + patch("mastermind.gamemode.computer.MastermindSession") as SessionClass, + patch( + "mastermind.gamemode.computer.Feedback", + _fake_feedback(side_effect=[_fb(0, 0), _fb(1, 1), _fb(4, 0)]), + ), + patch("mastermind.gamemode.computer.display", return_value="1234"), + patch("mastermind.gamemode.computer.console"), + patch("mastermind.gamemode.computer.pause"), + ): + ask_settings.return_value = (6, 4, 10) + ask_secret.return_value = 0 + session = _make_session([1, 2, 3]) + SessionClass.return_value = session + + from mastermind.gamemode.computer import play + + play() + + # recordGuess called for every guess including the winning one + assert session.recordGuess.call_count == 3 + + def test_out_of_tries_shows_secret(self): + with ( + patch("mastermind.gamemode.computer.ask_game_settings") as ask_settings, + patch("mastermind.gamemode.computer.ask_secret") as ask_secret, + patch("mastermind.gamemode.computer.MastermindSession") as SessionClass, + patch( + "mastermind.gamemode.computer.Feedback", + _fake_feedback(side_effect=[_fb(0, 0), _fb(1, 0)]), + ), + patch("mastermind.gamemode.computer.display", return_value="5555"), + patch("mastermind.gamemode.computer.console") as console, + patch("mastermind.gamemode.computer.pause") as pause, + ): + ask_settings.return_value = (6, 4, 2) + ask_secret.return_value = 0 + SessionClass.return_value = _make_session([1, 2]) + + from mastermind.gamemode.computer import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "failed" in printed.lower() or "✗" in printed + assert "5555" in printed + pause.assert_called_once() + + def test_each_guess_printed(self): + with ( + patch("mastermind.gamemode.computer.ask_game_settings") as ask_settings, + patch("mastermind.gamemode.computer.ask_secret") as ask_secret, + patch("mastermind.gamemode.computer.MastermindSession") as SessionClass, + patch( + "mastermind.gamemode.computer.Feedback", + _fake_feedback(side_effect=[_fb(2, 1), _fb(4, 0)]), + ), + patch("mastermind.gamemode.computer.display", return_value="1234"), + patch("mastermind.gamemode.computer.console") as console, + patch("mastermind.gamemode.computer.pause"), + ): + ask_settings.return_value = (6, 4, 10) + ask_secret.return_value = 0 + SessionClass.return_value = _make_session([1, 2]) + + from mastermind.gamemode.computer import play + + play() + + printed_lines = [str(c) for c in console.print.call_args_list] + guess_lines = [line for line in printed_lines if "Guess" in line] + assert len(guess_lines) == 2 diff --git a/src/test/python/mastermind/test_gamemode_human.py b/src/test/python/mastermind/test_gamemode_human.py new file mode 100644 index 00000000..b2f3210d --- /dev/null +++ b/src/test/python/mastermind/test_gamemode_human.py @@ -0,0 +1,174 @@ +"""Tests for human.play() — Python-side logic only. + +Mocks: + - ask_game_settings / ask_secret (setup prompts) + - Prompt.ask (guess input) + - Feedback (Java class — whole object replaced) + - parse_code (Java-backed, tested separately) + - display (Java-backed, tested separately) + - console / pause (output side-effects) +""" + +from unittest.mock import MagicMock, patch + + +def _fb(black, white): + return black * 10 + white + + +def _fake_feedback(return_value=None, side_effect=None): + """Return a mock that replaces the Feedback Java class in the module namespace.""" + fb = MagicMock() + if side_effect is not None: + fb.getFeedback.side_effect = side_effect + else: + fb.getFeedback.return_value = return_value + return fb + + +class TestHumanPlay: + def test_win_on_first_guess(self): + with ( + patch("mastermind.gamemode.human.ask_game_settings") as ask_settings, + patch("mastermind.gamemode.human.ask_secret") as ask_secret, + patch("mastermind.gamemode.human.Prompt.ask") as prompt, + patch("mastermind.gamemode.human.Feedback", _fake_feedback(_fb(4, 0))), + patch("mastermind.gamemode.human.parse_code", return_value=42), + patch("mastermind.gamemode.human.display", return_value="1234"), + patch("mastermind.gamemode.human.console") as console, + patch("mastermind.gamemode.human.pause") as pause, + ): + ask_settings.return_value = (6, 4, 10) + ask_secret.return_value = 0 + prompt.return_value = "1234" + + from mastermind.gamemode.human import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "Congratulations" in printed or "✓" in printed + pause.assert_called_once() + + def test_win_message_uses_singular_try(self): + with ( + patch("mastermind.gamemode.human.ask_game_settings") as ask_settings, + patch("mastermind.gamemode.human.ask_secret") as ask_secret, + patch("mastermind.gamemode.human.Prompt.ask") as prompt, + patch("mastermind.gamemode.human.Feedback", _fake_feedback(_fb(4, 0))), + patch("mastermind.gamemode.human.parse_code", return_value=42), + patch("mastermind.gamemode.human.display", return_value="1234"), + patch("mastermind.gamemode.human.console") as console, + patch("mastermind.gamemode.human.pause"), + ): + ask_settings.return_value = (6, 4, 10) + ask_secret.return_value = 0 + prompt.return_value = "1234" + + from mastermind.gamemode.human import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "try" in printed # singular: "1 try" + + def test_win_message_uses_plural_tries(self): + with ( + patch("mastermind.gamemode.human.ask_game_settings") as ask_settings, + patch("mastermind.gamemode.human.ask_secret") as ask_secret, + patch("mastermind.gamemode.human.Prompt.ask") as prompt, + patch( + "mastermind.gamemode.human.Feedback", + _fake_feedback(side_effect=[_fb(0, 0), _fb(4, 0)]), + ), + patch("mastermind.gamemode.human.parse_code", return_value=42), + patch("mastermind.gamemode.human.display", return_value="1234"), + patch("mastermind.gamemode.human.console") as console, + patch("mastermind.gamemode.human.pause"), + ): + ask_settings.return_value = (6, 4, 10) + ask_secret.return_value = 0 + prompt.side_effect = ["1111", "1234"] + + from mastermind.gamemode.human import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "tries" in printed # plural: "2 tries" + + def test_out_of_tries_shows_secret(self): + with ( + patch("mastermind.gamemode.human.ask_game_settings") as ask_settings, + patch("mastermind.gamemode.human.ask_secret") as ask_secret, + patch("mastermind.gamemode.human.Prompt.ask") as prompt, + patch( + "mastermind.gamemode.human.Feedback", + _fake_feedback(side_effect=[_fb(0, 0), _fb(1, 0)]), + ), + patch("mastermind.gamemode.human.parse_code", return_value=42), + patch("mastermind.gamemode.human.display", return_value="5555"), + patch("mastermind.gamemode.human.console") as console, + patch("mastermind.gamemode.human.pause") as pause, + ): + ask_settings.return_value = (6, 4, 2) + ask_secret.return_value = 0 + prompt.side_effect = ["1111", "2222"] + + from mastermind.gamemode.human import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "Out of tries" in printed or "✗" in printed + assert "5555" in printed + pause.assert_called_once() + + def test_invalid_guess_retried(self): + with ( + patch("mastermind.gamemode.human.ask_game_settings") as ask_settings, + patch("mastermind.gamemode.human.ask_secret") as ask_secret, + patch("mastermind.gamemode.human.Prompt.ask") as prompt, + patch("mastermind.gamemode.human.Feedback", _fake_feedback(_fb(4, 0))), + patch("mastermind.gamemode.human.parse_code") as parse_code, + patch("mastermind.gamemode.human.display", return_value="1234"), + patch("mastermind.gamemode.human.console") as console, + patch("mastermind.gamemode.human.pause"), + ): + ask_settings.return_value = (6, 4, 10) + ask_secret.return_value = 0 + prompt.side_effect = ["bad", "1234"] + parse_code.side_effect = [None, 42] + + from mastermind.gamemode.human import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "Invalid" in printed + assert prompt.call_count == 2 + + def test_feedback_printed_each_turn(self): + with ( + patch("mastermind.gamemode.human.ask_game_settings") as ask_settings, + patch("mastermind.gamemode.human.ask_secret") as ask_secret, + patch("mastermind.gamemode.human.Prompt.ask") as prompt, + patch( + "mastermind.gamemode.human.Feedback", + _fake_feedback(side_effect=[_fb(1, 2), _fb(4, 0)]), + ), + patch("mastermind.gamemode.human.parse_code", return_value=42), + patch("mastermind.gamemode.human.display", return_value="1234"), + patch("mastermind.gamemode.human.console") as console, + patch("mastermind.gamemode.human.pause"), + ): + ask_settings.return_value = (6, 4, 10) + ask_secret.return_value = 0 + prompt.side_effect = ["1111", "1234"] + + from mastermind.gamemode.human import play + + play() + + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "Feedback" in printed diff --git a/src/test/python/mastermind/test_parse_feedback.py b/src/test/python/mastermind/test_parse_feedback.py new file mode 100644 index 00000000..01a18466 --- /dev/null +++ b/src/test/python/mastermind/test_parse_feedback.py @@ -0,0 +1,89 @@ +"""Tests for assisted._parse_feedback — pure function, no JVM needed.""" + +import pytest +from mastermind.gamemode.assisted import _parse_feedback + + +class TestValidFormats: + def test_xbyw_format(self): + assert _parse_feedback("2b1w", d=4) == 21 + + def test_xy_format_no_letters(self): + assert _parse_feedback("21", d=4) == 21 + + def test_space_separated(self): + assert _parse_feedback("2 1", d=4) == 21 + + def test_zero_blacks_zero_whites(self): + assert _parse_feedback("0b0w", d=4) == 0 + + def test_full_black(self): + assert _parse_feedback("4b0w", d=4) == 40 + + def test_zero_blacks_with_whites(self): + assert _parse_feedback("0b3w", d=4) == 3 + + def test_leading_trailing_whitespace(self): + assert _parse_feedback(" 2b1w ", d=4) == 21 + + def test_uppercase_letters(self): + assert _parse_feedback("2B1W", d=4) == 21 + + def test_mixed_case(self): + assert _parse_feedback("2B1w", d=4) == 21 + + def test_xb_only_no_w(self): + # e.g. "2b1" — b present, w absent — regex allows this + assert _parse_feedback("2b1", d=4) == 21 + + def test_exact_sum_equals_d(self): + assert _parse_feedback("2b2w", d=4) == 22 + + def test_one_digit_d(self): + assert _parse_feedback("1b0w", d=1) == 10 + + +class TestInvalidFormats: + def test_empty_string(self): + assert _parse_feedback("", d=4) is None + + def test_letters_only(self): + assert _parse_feedback("abcd", d=4) is None + + def test_only_one_number(self): + assert _parse_feedback("2", d=4) is None + + def test_three_numbers(self): + assert _parse_feedback("2b1w3", d=4) is None + + def test_sum_exceeds_d(self): + assert _parse_feedback("3b2w", d=4) is None + + def test_whites_alone_exceed_d(self): + assert _parse_feedback("0b5w", d=4) is None + + def test_negative_not_representable(self): + # Can't enter a negative digit via the regex, but a value > 9 is also invalid format + assert ( + _parse_feedback("10b0w", d=10) is None + ) # two-digit blacks → regex won't match + + +class TestEncoding: + """Verify the black*10 + white encoding for a range of inputs.""" + + @pytest.mark.parametrize( + "black,white,d", + [ + (0, 0, 4), + (1, 0, 4), + (0, 1, 4), + (3, 1, 4), + (4, 0, 4), + (0, 4, 4), + (1, 3, 4), + ], + ) + def test_encoding(self, black, white, d): + raw = f"{black}b{white}w" + assert _parse_feedback(raw, d) == black * 10 + white diff --git a/src/tests/python/mastermind/__init__.py b/src/tests/python/mastermind/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/uv.lock b/uv.lock index f2781493..6c54289b 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,24 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "install-jdk" version = "1.1.0" @@ -49,7 +67,7 @@ wheels = [ [[package]] name = "mastermind-ai" -version = "2.1.0b0" +version = "2.2.0" source = { editable = "." } dependencies = [ { name = "install-jdk" }, @@ -57,6 +75,11 @@ dependencies = [ { name = "rich" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "install-jdk", specifier = ">=1.1.0" }, @@ -64,6 +87,9 @@ requires-dist = [ { name = "rich", specifier = ">=14.3.3" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.2" }] + [[package]] name = "mdurl" version = "0.1.2" @@ -82,6 +108,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -91,6 +126,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "rich" version = "14.3.3"