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 @@
+
+
-| **Version:** | [](https://github.com/FlysonBot/Mastermind/tags) [](https://github.com/FlysonBot/Mastermind/releases) [](https://www.python.org/downloads/) [](https://pypi.org/project/mastermind-ai/) |
+
+| **Testing:** | [](https://github.com/FlysonBot/Mastermind/actions/workflows/coverage.yaml) [](https://coveralls.io/github/FlysonBot/Mastermind?branch=main) [](https://www.codefactor.io/repository/github/flysonbot/mastermind/overview/main) [](https://app.codacy.com/gh/FlysonBot/Mastermind/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)|
| --- | :-: |
+| **Version:** | [](https://github.com/FlysonBot/Mastermind/tags) [](https://github.com/FlysonBot/Mastermind/releases) [](https://www.python.org/downloads/) [](https://pypi.org/project/mastermind-ai/) |
| **Meta:** | [](https://github.com/FlysonBot/Mastermind/blob/main/LICENSE)   [](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"