Skip to content

Commit b49dd96

Browse files
Merge pull request steam-bell-92#780 from jbkun069/fix/number-guessing-input-validation
fix: improve input validation and add proximity hints in number …
2 parents 3413b2c + c079646 commit b49dd96

2 files changed

Lines changed: 168 additions & 44 deletions

File tree

Lines changed: 119 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,136 @@
11
import random
2+
from typing import Tuple, List
3+
4+
# Game constants
5+
MIN_NUMBER = 1
6+
MAX_NUMBER = 100
7+
8+
# Difficulty levels
9+
DIFFICULTY_LEVELS = {
10+
"1": {"level": "Easy", "attempts": 15},
11+
"2": {"level": "Medium", "attempts": 10},
12+
"3": {"level": "Hard", "attempts": 5},
13+
}
14+
DEFAULT_DIFFICULTY = "2"
15+
16+
# Proximity hint thresholds
17+
VERY_CLOSE_THRESHOLD = 3
18+
CLOSE_THRESHOLD = 10
19+
NOT_CLOSE_THRESHOLD = 20
20+
21+
22+
def validate_guess(guess_num: int) -> bool:
23+
"""Check if guess is within valid range."""
24+
return MIN_NUMBER <= guess_num <= MAX_NUMBER
25+
26+
27+
def get_proximity_hint(diff: int) -> str:
28+
"""Return a hint based on how close the guess is."""
29+
if diff <= VERY_CLOSE_THRESHOLD:
30+
return "Very close!"
31+
elif diff <= CLOSE_THRESHOLD:
32+
return "Close."
33+
elif diff <= NOT_CLOSE_THRESHOLD:
34+
return "Not very close."
35+
else:
36+
return "Far off."
37+
38+
39+
def choose_difficulty() -> int:
40+
"""Let the user choose a difficulty level."""
41+
print("Choose difficulty:")
42+
print(" 1) Easy - 15 attempts")
43+
print(" 2) Medium - 10 attempts")
44+
print(" 3) Hard - 5 attempts")
245

3-
4-
def choose_difficulty():
5-
print("🎯 Choose difficulty:")
6-
print(" 1) 🟢 Easy - 15 attempts")
7-
print(" 2) 🟡 Medium - 10 attempts")
8-
print(" 3) 🔴 Hard - 5 attempts")
946
while True:
10-
choice = input("Select 1, 2 or 3 (default 2): ").strip() or "2"
11-
if choice in ("1", "2", "3"):
12-
return {"1": 15, "2": 10, "3": 5}[choice]
13-
print("⚠️ Invalid selection. Please enter 1, 2 or 3.")
47+
choice = input("Select 1, 2 or 3 (default 2): ").strip() or DEFAULT_DIFFICULTY
48+
49+
if choice in DIFFICULTY_LEVELS:
50+
return DIFFICULTY_LEVELS[choice]["attempts"]
51+
52+
print("Invalid selection. Please enter 1, 2 or 3.")
53+
54+
55+
def get_guess_input(remaining: int) -> int | None:
56+
"""
57+
Get and validate user input.
58+
59+
Args:
60+
remaining: Attempts left
61+
62+
Returns:
63+
Valid guess number or None
64+
"""
65+
try:
66+
guess = input(f"Guess ({remaining} left): ").strip()
67+
68+
if not guess:
69+
print("Input cannot be empty.")
70+
return None
71+
72+
guess_num = int(guess)
73+
74+
if not validate_guess(guess_num):
75+
print(f"Enter a number between {MIN_NUMBER} and {MAX_NUMBER}.")
76+
return None
1477

78+
return guess_num
79+
80+
except ValueError:
81+
print("Invalid input. Please enter a whole number.")
82+
return None
83+
84+
85+
def play_round(max_attempts: int) -> Tuple[bool, int]:
86+
"""
87+
Run one round of the game.
1588
16-
def play_round(max_attempts: int):
17-
number = random.randint(1, 100)
89+
Args:
90+
max_attempts: Maximum guesses allowed
91+
92+
Returns:
93+
Tuple of (won, attempts_used)
94+
"""
95+
number = random.randint(MIN_NUMBER, MAX_NUMBER)
1896
attempts = 0
19-
print(f"I'm thinking of a number between 1 and 100. You have {max_attempts} attempts.")
20-
guess_history = []
97+
guess_history: List[int] = []
98+
99+
print(f"I'm thinking of a number between {MIN_NUMBER} and {MAX_NUMBER}.")
100+
print(f"You have {max_attempts} attempts.")
21101

22102
while attempts < max_attempts:
23103
remaining = max_attempts - attempts
24-
try:
25-
guess = input(f"Guess ({remaining} left): ").strip()
26-
guess_num = int(guess)
27-
except ValueError:
28-
print("⚠️ Invalid input — please enter an integer between 1 and 100.")
29-
continue
104+
guess_num = get_guess_input(remaining)
30105

31-
if not (1 <= guess_num <= 100):
32-
print("🚫 Out of range — enter a number between 1 and 100.")
106+
if guess_num is None:
33107
continue
34108

35109
attempts += 1
36110
guess_history.append(guess_num)
37111

38112
if guess_num == number:
39-
print("🏆 Correct! You guessed the number!")
40-
print(f"✨ number: {number} — attempts: {attempts}/{max_attempts}")
113+
print("Correct! You guessed the number.")
114+
print(f"Number: {number}")
115+
print(f"Attempts: {attempts}/{max_attempts}")
116+
print("Guesses:", ", ".join(map(str, guess_history)))
41117
return True, attempts
42118

43119
if guess_num > number:
44-
print("⬇️ High — try a smaller number.")
120+
print("Too high.")
45121
else:
46-
print("⬆️ Low — try a larger number.")
122+
print("Too low.")
47123

48-
# proximity hint
124+
# Show proximity hint
49125
diff = abs(guess_num - number)
50-
if diff <= 3:
51-
print("🔥 Very close!")
52-
elif diff <= 10:
53-
print("🙂 Close — you're getting there.")
54-
elif diff <= 20:
55-
print("🧭 Not very close yet.")
56-
else:
57-
print("❄️ Far off — try a different range.")
126+
print(get_proximity_hint(diff))
58127

59-
print("💥 Out of attempts — you lost this round.")
128+
print("Out of attempts.")
60129
print(f"The number was {number}.")
130+
61131
if guess_history:
62132
print("Your guesses:", ", ".join(map(str, guess_history)))
133+
63134
return False, attempts
64135

65136

@@ -71,21 +142,28 @@ def play_round_with_guesses(guesses, number, max_attempts: int):
71142
returns: (won: bool, attempts: int)
72143
"""
73144
attempts = 0
145+
74146
for g in guesses:
75147
if attempts >= max_attempts:
76148
break
149+
77150
if not isinstance(g, int):
78151
continue
79-
if not (1 <= g <= 100):
152+
153+
if not validate_guess(g):
80154
continue
155+
81156
attempts += 1
157+
82158
if g == number:
83159
return True, attempts
160+
84161
return False, attempts
85162

86163

87164
def main():
88-
print("🎮 Welcome to the Number Guessing Game! 🎮\n")
165+
print("Welcome to the Number Guessing Game!\n")
166+
89167
while True:
90168
max_attempts = choose_difficulty()
91169
won, tries = play_round(max_attempts)
@@ -96,10 +174,11 @@ def main():
96174
print(f"You used all {max_attempts} attempts.")
97175

98176
again = input("Play again? (y/n): ").strip().lower()
177+
99178
if again != "y":
100-
print("👋 Thanks for playing — goodbye!")
179+
print("Goodbye.")
101180
break
102181

103182

104183
if __name__ == "__main__":
105-
main()
184+
main()

tests/test_number_guessing.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,77 @@
55

66
class TestNumberGuessing(unittest.TestCase):
77
def setUp(self):
8-
# Load the module dynamically since it has hyphens in the name
98
module_name = "number_guessing"
109
file_path = os.path.join("games", "Number-Guessing-Game", "Number-Guessing-Game.py")
1110

1211
spec = importlib.util.spec_from_file_location(module_name, file_path)
12+
if spec is None or spec.loader is None:
13+
raise ImportError(f"Could not load module spec for: {file_path}")
14+
1315
self.module = importlib.util.module_from_spec(spec)
1416
sys.modules[module_name] = self.module
1517
spec.loader.exec_module(self.module)
1618

19+
# Existing tests
1720
def test_play_round_with_guesses_win(self):
18-
# Test winning exactly on the last attempt
1921
won, attempts = self.module.play_round_with_guesses([10, 20, 50], number=50, max_attempts=3)
2022
self.assertTrue(won)
2123
self.assertEqual(attempts, 3)
2224

2325
def test_play_round_with_guesses_lose(self):
24-
# Test running out of attempts
2526
won, attempts = self.module.play_round_with_guesses([10, 20, 30], number=50, max_attempts=3)
2627
self.assertFalse(won)
2728
self.assertEqual(attempts, 3)
2829

2930
def test_play_round_with_guesses_win_early(self):
30-
# Test winning before max attempts are reached
3131
won, attempts = self.module.play_round_with_guesses([50, 60, 70], number=50, max_attempts=5)
3232
self.assertTrue(won)
3333
self.assertEqual(attempts, 1)
3434

35+
# NEW TESTS for refactored functions
36+
def test_validate_guess_valid(self):
37+
"""Test that valid guesses are accepted."""
38+
self.assertTrue(self.module.validate_guess(1))
39+
self.assertTrue(self.module.validate_guess(50))
40+
self.assertTrue(self.module.validate_guess(100))
41+
42+
def test_validate_guess_invalid(self):
43+
"""Test that out-of-range guesses are rejected."""
44+
self.assertFalse(self.module.validate_guess(0))
45+
self.assertFalse(self.module.validate_guess(101))
46+
self.assertFalse(self.module.validate_guess(-5))
47+
48+
def test_proximity_hint_very_close(self):
49+
"""Test proximity hint for very close guesses."""
50+
hint = self.module.get_proximity_hint(1)
51+
self.assertIn("Very close", hint)
52+
53+
def test_proximity_hint_close(self):
54+
"""Test proximity hint for close guesses."""
55+
hint = self.module.get_proximity_hint(5)
56+
self.assertIn("Close", hint)
57+
58+
def test_proximity_hint_not_close(self):
59+
"""Test proximity hint for distant guesses."""
60+
hint = self.module.get_proximity_hint(15)
61+
self.assertIn("Not very close", hint)
62+
63+
def test_proximity_hint_far(self):
64+
"""Test proximity hint for very far guesses."""
65+
hint = self.module.get_proximity_hint(50)
66+
self.assertIn("Far off", hint)
67+
68+
def test_play_round_with_guesses_invalid_types(self):
69+
"""Test that non-integer guesses are skipped."""
70+
won, attempts = self.module.play_round_with_guesses(["50", 50], number=50, max_attempts=2)
71+
self.assertTrue(won)
72+
self.assertEqual(attempts, 1) # Only the integer 50 counts
73+
74+
def test_play_round_with_guesses_out_of_range(self):
75+
"""Test that out-of-range guesses are skipped."""
76+
won, attempts = self.module.play_round_with_guesses([150, 50], number=50, max_attempts=2)
77+
self.assertTrue(won)
78+
self.assertEqual(attempts, 1) # Only 50 counts, 150 is skipped
79+
3580
if __name__ == "__main__":
3681
unittest.main()

0 commit comments

Comments
 (0)