From 78dd847cebe5a6434295a98baa7355e56e696316 Mon Sep 17 00:00:00 2001 From: Hassan Shabbir Ahmed Date: Sat, 30 Nov 2024 12:08:42 +0000 Subject: [PATCH] refactor: Improve code structure and test coverage - Reorganized project structure - Added comprehensive test suite - Fixed GUI tests with proper ttk widget handling - Updated requirements.txt with missing dependencies - Improved error handling and code organization - Removed duplicate test files - Added proper test fixtures and configurations --- .coverage | Bin 0 -> 53248 bytes assets/difficulties.json | 8 +- assets/wordlist.txt | 100 +++++++++++++ high_scores.py | 77 ---------- main.py | 283 ----------------------------------- requirements.txt | 7 +- src/config/config_manager.py | 4 +- src/game_logic.py | 124 ++++++++------- src/gui.py | 279 +++++++++++++++++++++------------- src/high_scores.py | 89 ++++++----- src/main.py | 2 +- src/utils.py | 54 ++++--- test_high_scores.py | 87 ----------- test_typing_speed.py | 213 -------------------------- tests/conftest.py | 104 ++++++------- tests/test_game_logic.py | 57 +++---- tests/test_gui.py | 99 ++++++++++++ tests/test_high_scores.py | 58 ++++--- tests/test_main.py | 20 +++ tests/test_typing_speed.py | 95 ------------ tests/test_utils.py | 58 ++++--- 21 files changed, 700 insertions(+), 1118 deletions(-) create mode 100644 .coverage create mode 100644 assets/wordlist.txt delete mode 100644 high_scores.py delete mode 100644 main.py delete mode 100644 test_high_scores.py delete mode 100644 test_typing_speed.py create mode 100644 tests/test_gui.py create mode 100644 tests/test_main.py delete mode 100644 tests/test_typing_speed.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..64b46012119485fc18ea582a8160b07f6056e19d GIT binary patch literal 53248 zcmeI4{fi_=8OOV)d#0y%cc$(LLsh#a) zrhBiu=kC@JbQ0k}$S?7S`$hZ%3_-aF8X@Kfe-K2FBM1@@N$@3@L@~aAxzqSO{jxK2 zcN0QiAeZyo%v5z(S3UhapL(ils(ZTbzVkLOR&p~4`)(|6()MV&u6<5QP19`p*65pW z7VVh%0mb^*{}KbblVY`rpW^{lD5@Vxgo>g zl-yRK-1K@%dVa@iyRqkYWxS>G+=EDUc%Y*+8tORXT2h|(9Ir#AV$~%P+o9KY!yWmc z+F405WLJyT={QXwA)2}Y6oG5wQq&4-`-Nuigb4^YzL(0={U7E4EoJDkUh^&%=9BK_5xq3 z)2cm)Rp*KZt}wKGBY5N1kgWUJp!?I3*|o;#j0#_&WR^WEyh+OWL5_7)kS|Sz6P^7* zzeUI&81%Qfa;QXl;W(I9a$4Cq$c%la{ew&+0#5S~plPYw(YArjPb_?&cBa!Q@+m*(V z#c8gy>jKXD?(WQRf5mDnEa;Eei5q3Ll||Ns3*|L=g>E!I)mLsb2=k8Q%{s}v#Hg3d z+emhX%vrT6jg5tA=4APt^=zj)v`nipKd%qd7Qy`zf6Eg_aLT9&Trv>6b@jL1cuQWc zJ13LkOXW)AQ}fdlXBC`v4l9p-wUnrQW`9DzPn|x0jR}=GWu)>K)XwTjw@1Cnw(Et| zWT_8MM*ztgJ1%8*Tfrbs{Fe_C_vEo>q;D!RX4=&uzAE!o_w=df1 z_g%k}jaNJjr&6i!=f=s`NjniLD3_>FM$|+vk(mq4&T@~H%U|0jCM>n@cGBj~l`>b&zuZn8F8$gW_Y-C}hJz2&D{$wQ)?h28kmt&N z>&z_k1YZ{yH2Poz0T2KI5C8!X009sH0T2KI5C8!Xxc&$jdP%o<{$CO=XyUIFU;_aV z009sH0T2KI5C8!X009sH0T6h15|}F)H;DW%9v>09vCzo=1n{x7kFI@uhO8=ymo)K` z_~*ORKxhpDAOHd&00JNY0w4eaAOHd&00JP86PPn@(DSvfxl-Kb5ktKSg@52$t=HRGLaTKdI_ww3BQv5Mkm6t`KL(2lt0vsU;{bHGfE?%8b$MJkG zb{rCSi%H`478Dn$m{$>ET!#&kGh0y3AohAJU-o*`jjt`M+`09L^UNGBN)z-9ly+y_qzI2kZZ#X958b009sH0T2KI5C8!X z009sHf$N%pp&MF_pa1LPqDCKVAOHd&00JNY0w4eaAOHd&00JNY0@on{!>HNG`~M== z#J|O##Pi};;^*Qi@tAm+lCXgQ2!H?xfB*=900@8p2!H?xfB*v<+`!*+J!fQhtB@~m*2Sf#=YfAnbPXzyKIx<_nMDiyh)!mX!m`_xs%V_+t>G$ z=->mTpFa8QSDya!*Pi^|Ppq|6~6D_H%_q5C8!X009sH0T2KI z5C8!X009uVmI$Qp|3An7|Nocxn|Os*0eDgTmfit)?po3kx_|%(fB*=900@8p2!H?x kfB*=900>+{z_4juf1}EQ#X*IGG6yCH1_vb$c>VwX0UW7a<^TWy literal 0 HcmV?d00001 diff --git a/assets/difficulties.json b/assets/difficulties.json index e156fb7..c1d2076 100644 --- a/assets/difficulties.json +++ b/assets/difficulties.json @@ -1,8 +1,8 @@ { "easy": { "words": 15, - "time_limit": null, - "description": "Practice mode with no time limit" + "time_limit": 120, + "description": "Practice mode with 2-minute time limit" }, "medium": { "words": 25, @@ -11,7 +11,7 @@ }, "hard": { "words": 40, - "time_limit": 60, - "description": "Challenge mode with more words" + "time_limit": 45, + "description": "Challenge mode with 45-second time limit" } } diff --git a/assets/wordlist.txt b/assets/wordlist.txt new file mode 100644 index 0000000..8794c5b --- /dev/null +++ b/assets/wordlist.txt @@ -0,0 +1,100 @@ +the +be +to +of +and +a +in +that +have +I +it +for +not +on +with +he +as +you +do +at +this +but +his +by +from +they +we +say +her +she +or +an +will +my +one +all +would +there +their +what +so +up +out +if +about +who +get +which +go +me +when +make +can +like +time +no +just +him +know +take +people +into +year +your +good +some +could +them +see +other +than +then +now +look +only +come +its +over +think +also +back +after +use +two +how +our +work +first +well +way +even +new +want +because +any +these +give +day +most +us diff --git a/high_scores.py b/high_scores.py deleted file mode 100644 index e590fa0..0000000 --- a/high_scores.py +++ /dev/null @@ -1,77 +0,0 @@ -import json -import os -from datetime import datetime - -class HighScores: - def __init__(self): - self.scores_file = "typing_scores.json" - self.scores = self._create_default_scores() - self._load_scores() - - def _load_scores(self): - """Load scores from file if it exists""" - if os.path.exists(self.scores_file): - try: - with open(self.scores_file, 'r') as f: - loaded_scores = json.load(f) - # Only load valid scores - for diff in ['easy', 'medium', 'hard']: - if diff in loaded_scores and isinstance(loaded_scores[diff], list): - self.scores[diff] = loaded_scores[diff] - except (json.JSONDecodeError, FileNotFoundError): - pass # Keep default scores - - def _create_default_scores(self): - """Create a new scores dictionary with empty lists""" - return { - 'easy': [], - 'medium': [], - 'hard': [] - } - - def add_score(self, difficulty, wpm, accuracy): - """Add a new score for the given difficulty""" - if difficulty not in self.scores: - self.scores[difficulty] = [] - - score = { - 'wpm': float(round(wpm, 1)), - 'accuracy': float(round(accuracy, 1)), - 'date': datetime.now().strftime("%Y-%m-%d %H:%M:%S") - } - - self.scores[difficulty].append(score) - self.scores[difficulty].sort(key=lambda x: (-x['wpm'], -x['accuracy'])) - self.scores[difficulty] = self.scores[difficulty][:10] - - self._save_scores() - return score - - def get_high_scores(self, difficulty): - """Get high scores for the given difficulty""" - if difficulty not in self.scores: - self.scores[difficulty] = [] - return self.scores[difficulty].copy() # Return a copy to prevent modification - - def _save_scores(self): - """Save scores to file""" - try: - with open(self.scores_file, 'w') as f: - json.dump(self.scores, f, indent=2) - except Exception as e: - print(f"Error saving scores: {e}") - - def get_personal_best(self, difficulty): - """Get the personal best score for the given difficulty""" - scores = self.get_high_scores(difficulty) - return scores[0] if scores else None - - def clear_scores(self): - """Clear all scores (used for testing)""" - self.scores = self._create_default_scores() - if os.path.exists(self.scores_file): - os.remove(self.scores_file) - - def reset(self): - """Reset scores to default state (used for testing)""" - self.scores = self._create_default_scores() diff --git a/main.py b/main.py deleted file mode 100644 index e5994b1..0000000 --- a/main.py +++ /dev/null @@ -1,283 +0,0 @@ -import tkinter as tk -from tkinter import messagebox, ttk -import time -import random -from high_scores import HighScores - -class TypingSpeedTest: - def __init__(self, root): - self.root = root - self.root.title("Typing Speed Test") - self.root.geometry("800x400") - self.root.configure(bg="#f0f0f0") - - # Initialize variables - self.current_text = "" - self.start_time = None - self.high_scores = HighScores() - - # Difficulty settings - self.difficulties = { - 'easy': {'words': 15, 'time_limit': None}, - 'medium': {'words': 25, 'time_limit': 60}, - 'hard': {'words': 40, 'time_limit': 60} - } - self.current_difficulty = 'medium' - self.word_count = self.difficulties[self.current_difficulty]['words'] - self.time_limit = self.difficulties[self.current_difficulty]['time_limit'] - - # Sample words for the typing test - self.word_list = [ - "the", "be", "to", "of", "and", "a", "in", "that", "have", "I", - "it", "for", "not", "on", "with", "he", "as", "you", "do", "at", - "this", "but", "his", "by", "from", "they", "we", "say", "her", "she", - "or", "an", "will", "my", "one", "all", "would", "there", "their", "what", - "so", "up", "out", "if", "about", "who", "get", "which", "go", "me", - "computer", "programming", "python", "keyboard", "typing", "speed", "test", - "practice", "software", "developer", "coding", "learning", "skills" - ] - - # Create and pack widgets - self.create_widgets() - - def create_widgets(self): - # Title - title = tk.Label(self.root, text="Typing Speed Test", - font=("Helvetica", 24, "bold"), - bg="#f0f0f0", fg="#333333") - title.pack(pady=10) - - # Difficulty selector - difficulty_frame = tk.Frame(self.root, bg="#f0f0f0") - difficulty_frame.pack(pady=5) - - tk.Label(difficulty_frame, text="Difficulty:", - font=("Helvetica", 12), - bg="#f0f0f0").pack(side=tk.LEFT, padx=5) - - self.difficulty_var = tk.StringVar(value=self.current_difficulty) - difficulty_menu = ttk.OptionMenu( - difficulty_frame, - self.difficulty_var, - self.current_difficulty, - *self.difficulties.keys(), - command=self.change_difficulty - ) - difficulty_menu.pack(side=tk.LEFT) - - # High score display - self.high_score_label = tk.Label( - self.root, - text="", - font=("Helvetica", 10), - bg="#f0f0f0", - fg="#666666" - ) - self.high_score_label.pack(pady=5) - self.update_high_score_display() - - # Frame for text display - self.text_frame = tk.Frame(self.root, bg="#ffffff", - padx=20, pady=20) - self.text_frame.pack(fill=tk.BOTH, expand=True, padx=20) - - # Text widget to display text with colors - self.display_text = tk.Text(self.text_frame, - wrap=tk.WORD, - font=("Helvetica", 14), - height=4, - width=50, - bg="#ffffff") - self.display_text.pack(expand=True) - self.display_text.config(state='disabled') - - # Configure text tags for coloring - self.display_text.tag_configure("correct", foreground="green") - self.display_text.tag_configure("incorrect", foreground="red") - self.display_text.tag_configure("remaining", foreground="black") - - # Entry for typing - self.type_entry = tk.Entry(self.root, - font=("Helvetica", 14), - width=50) - self.type_entry.pack(pady=20) - self.type_entry.bind('', self.check_progress) - - # Start button - self.start_button = tk.Button(self.root, - text="Start Test", - command=self.start_test, - font=("Helvetica", 12), - bg="#4CAF50", - fg="white", - padx=20) - self.start_button.pack(pady=10) - - # Results label - self.results_label = tk.Label(self.root, - text="", - font=("Helvetica", 12), - bg="#f0f0f0") - self.results_label.pack(pady=10) - - def generate_text(self): - # Generate random text from our word list - selected_words = random.sample(self.word_list, self.word_count) - return " ".join(selected_words) - - def update_text_display(self, current_input): - self.display_text.config(state='normal') - self.display_text.delete(1.0, tk.END) - - # Compare current input with target text - target_words = self.current_text.split() - input_words = current_input.split() - remaining_words = target_words[len(input_words):] - - # Color the fully typed words - for i, (target_word, input_word) in enumerate(zip(target_words, input_words)): - # Add space before word (except first word) - if i > 0: - self.display_text.insert(tk.END, " ") - - # Compare characters in the current word - for j, (target_char, input_char) in enumerate(zip(target_word, input_word)): - if target_char == input_char: - self.display_text.insert(tk.END, target_char, "correct") - else: - self.display_text.insert(tk.END, target_char, "incorrect") - - # Handle any remaining characters in the target word - if len(target_word) > len(input_word): - if i > 0: - self.display_text.insert(tk.END, target_word[len(input_word):], "remaining") - - # Add any remaining words - if remaining_words: - if len(input_words) > 0: - self.display_text.insert(tk.END, " ") - self.display_text.insert(tk.END, " ".join(remaining_words), "remaining") - - self.display_text.config(state='disabled') - - def start_test(self): - self.current_text = self.generate_text() - self.display_text.config(state='normal') - self.display_text.delete(1.0, tk.END) - self.display_text.insert(1.0, self.current_text, "remaining") - self.display_text.config(state='disabled') - self.type_entry.delete(0, tk.END) - self.type_entry.config(state='normal') - self.start_time = time.time() - self.start_button.config(state='disabled') - self.results_label.config(text="") - self.type_entry.focus() - - def change_difficulty(self, difficulty): - self.current_difficulty = difficulty - self.word_count = self.difficulties[difficulty]['words'] - self.time_limit = self.difficulties[difficulty]['time_limit'] - self.update_high_score_display() - - # Update UI to show difficulty settings - words = self.difficulties[difficulty]['words'] - time = self.difficulties[difficulty]['time_limit'] or "∞" - messagebox.showinfo( - "Difficulty Changed", - f"Changed to {difficulty.title()}:\n" - f"- {words} words\n" - f"- Time limit: {time} seconds" - ) - - def update_high_score_display(self): - best_score = self.high_scores.get_personal_best(self.current_difficulty) - if best_score: - self.high_score_label.config( - text=f"Personal Best ({self.current_difficulty.title()}): " - f"{best_score['wpm']} WPM with {best_score['accuracy']}% accuracy" - ) - else: - self.high_score_label.config( - text=f"No high score yet for {self.current_difficulty.title()}" - ) - - def check_progress(self, event): - if not self.start_time: - return - - current_input = self.type_entry.get() - - # Check time limit - if self.time_limit: - time_elapsed = time.time() - self.start_time - if time_elapsed > self.time_limit: - self.end_test(self.calculate_accuracy(current_input)) - return - - # Update text colors - self.update_text_display(current_input) - - # Calculate accuracy and WPM - accuracy = self.calculate_accuracy(current_input) - if len(current_input) > 0: - time_elapsed = time.time() - self.start_time - words_typed = len(current_input.split()) - current_wpm = (words_typed / time_elapsed) * 60 - else: - accuracy = 0 - current_wpm = 0 - - # Check if test is complete - if len(current_input) >= len(self.current_text): - self.end_test(accuracy) - return - - # Update current accuracy and WPM - remaining = "" - if self.time_limit: - remaining = f" | Time: {max(0, self.time_limit - (time.time() - self.start_time)):.1f}s" - - self.results_label.config( - text=f"WPM: {current_wpm:.1f} | Accuracy: {accuracy:.1f}%{remaining}") - - def calculate_accuracy(self, current_input): - correct_chars = sum(1 for i, j in zip(current_input, self.current_text) if i == j) - total_chars = len(current_input) - return (correct_chars / total_chars * 100) if total_chars > 0 else 0 - - def end_test(self, final_accuracy): - end_time = time.time() - time_taken = end_time - self.start_time - - # Calculate WPM - word_count = len(self.current_text.split()) - wpm = (word_count / time_taken) * 60 - - # Save score - self.high_scores.add_score(self.current_difficulty, wpm, final_accuracy) - self.update_high_score_display() - - # Display results - result_text = f"Time: {time_taken:.1f}s | WPM: {wpm:.1f} | Accuracy: {final_accuracy:.1f}%" - self.results_label.config(text=result_text) - - self.type_entry.config(state='disabled') - self.start_button.config(state='normal') - - # Show detailed results - best_score = self.high_scores.get_personal_best(self.current_difficulty) - if best_score and best_score['wpm'] == round(wpm, 1): - messagebox.showinfo( - "Test Complete - New High Score!", - f"{result_text}\n\nNew Personal Best for {self.current_difficulty.title()}!" - ) - else: - messagebox.showinfo("Test Complete", result_text) - -def main(): - root = tk.Tk() - app = TypingSpeedTest(root) - root.mainloop() - -if __name__ == "__main__": - main() diff --git a/requirements.txt b/requirements.txt index 09e2b7b..8975471 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,11 @@ python-dotenv>=1.0.0 -pytest>=7.4.0 +pytest>=7.0.0 pytest-cov>=4.1.0 pytest-mock>=3.11.1 pytest-timeout>=2.1.0 black>=23.7.0 pylint>=2.17.5 -xvfbwrapper>=0.2.9 \ No newline at end of file +xvfbwrapper>=0.2.9 +pytest-xvfb>=2.0.0 +typing-extensions>=4.7.1 # For Python type hints +pathlib>=1.0.1 # For path handling \ No newline at end of file diff --git a/src/config/config_manager.py b/src/config/config_manager.py index f27f93c..1c69239 100644 --- a/src/config/config_manager.py +++ b/src/config/config_manager.py @@ -62,9 +62,9 @@ def _load_config(self) -> None: difficulties_file = assets_dir / 'difficulties.json' if not difficulties_file.exists(): difficulties = { - 'easy': {'words': 15, 'time_limit': None}, + 'easy': {'words': 15, 'time_limit': 120}, 'medium': {'words': 25, 'time_limit': 60}, - 'hard': {'words': 40, 'time_limit': 60} + 'hard': {'words': 40, 'time_limit': 45} } difficulties_file.write_text(json.dumps(difficulties, indent=4)) else: diff --git a/src/game_logic.py b/src/game_logic.py index 80dba5e..2a2fc58 100644 --- a/src/game_logic.py +++ b/src/game_logic.py @@ -1,71 +1,83 @@ -""" -Core game logic for the Typing Speed Test application. -""" -from typing import List, Dict, Optional, Tuple -import random +"""Game logic for the typing speed test.""" import time -from src.utils import calculate_wpm, calculate_accuracy, load_word_list -from src.settings import DIFFICULTIES +import random +from pathlib import Path +from typing import Dict, List, Optional + +from .utils import calculate_wpm, calculate_accuracy +from .settings import DIFFICULTIES class GameManager: - """Manages the core game logic for the typing speed test.""" - - def __init__(self): - self.word_list: List[str] = load_word_list() - self.current_text: str = "" + """Manages game state and logic.""" + + def __init__(self, word_list_file: Path): + """Initialize game manager.""" + self.word_list_file = Path(word_list_file) + self.word_list: List[str] = [] + self.current_text = "" self.start_time: Optional[float] = None - self.current_difficulty: str = 'medium' - self.word_count: int = DIFFICULTIES[self.current_difficulty]['words'] - self.time_limit: Optional[int] = DIFFICULTIES[self.current_difficulty]['time_limit'] + self.difficulty = 'medium' + self.word_count = DIFFICULTIES[self.difficulty]['words'] + self.time_limit = DIFFICULTIES[self.difficulty]['time_limit'] + + self._load_words() + + def _load_words(self) -> None: + """Load word list from file.""" + with open(self.word_list_file, 'r') as f: + self.word_list = [word.strip() for word in f.readlines() if word.strip()] + + def set_difficulty(self, difficulty: str) -> None: + """Set game difficulty.""" + if difficulty not in DIFFICULTIES: + raise ValueError(f"Invalid difficulty: {difficulty}") + self.difficulty = difficulty + self.word_count = DIFFICULTIES[difficulty]['words'] + self.time_limit = DIFFICULTIES[difficulty]['time_limit'] + def generate_text(self) -> str: - """Generate random text for typing test based on current difficulty.""" - selected_words = random.sample(self.word_list, self.word_count) - self.current_text = ' '.join(selected_words) + """Generate text for typing test.""" + if len(self.word_list) < self.word_count: + # If not enough words, duplicate the list + self.word_list = self.word_list * (self.word_count // len(self.word_list) + 1) + + words = random.sample(self.word_list, self.word_count) + self.current_text = " ".join(words) return self.current_text - - def start_game(self) -> None: + + def start_game(self, difficulty: Optional[str] = None) -> None: """Start a new game.""" - self.start_time = time.time() + if difficulty: + self.set_difficulty(difficulty) self.generate_text() - - def set_difficulty(self, difficulty: str) -> None: - """Set the game difficulty.""" - if difficulty in DIFFICULTIES: - self.current_difficulty = difficulty - self.word_count = DIFFICULTIES[difficulty]['words'] - self.time_limit = DIFFICULTIES[difficulty]['time_limit'] - + self.start_time = time.time() + + def reset(self) -> None: + """Reset game state.""" + self.current_text = "" + self.start_time = None + + def get_elapsed_time(self) -> float: + """Get elapsed time since game start.""" + if not self.start_time: + return 0.0 + return time.time() - self.start_time + + def is_time_up(self) -> bool: + """Check if time limit is reached.""" + if not self.time_limit or not self.start_time: + return False + return self.get_elapsed_time() >= self.time_limit + def calculate_results(self, typed_text: str) -> Dict[str, float]: """Calculate typing test results.""" - if not self.start_time: - return {'wpm': 0, 'accuracy': 0, 'time': 0} - - end_time = time.time() - time_taken = end_time - self.start_time - - # Calculate words per minute - word_count = len(typed_text.split()) - wpm = calculate_wpm(word_count, time_taken) - - # Calculate accuracy + elapsed_time = self.get_elapsed_time() + wpm = calculate_wpm(typed_text, elapsed_time) accuracy = calculate_accuracy(typed_text, self.current_text) return { - 'wpm': round(wpm, 2), - 'accuracy': round(accuracy, 2), - 'time': round(time_taken, 2) + 'wpm': wpm, + 'accuracy': accuracy, + 'time': elapsed_time } - - def is_time_up(self) -> bool: - """Check if the time limit has been reached.""" - if not self.time_limit or not self.start_time: - return False - return (time.time() - self.start_time) >= self.time_limit - - def get_remaining_time(self) -> Optional[int]: - """Get remaining time in seconds.""" - if not self.time_limit or not self.start_time: - return None - remaining = self.time_limit - (time.time() - self.start_time) - return max(0, int(remaining)) diff --git a/src/gui.py b/src/gui.py index c026ef7..65e8ad6 100644 --- a/src/gui.py +++ b/src/gui.py @@ -3,64 +3,69 @@ """ import tkinter as tk from tkinter import ttk, messagebox -from typing import Callable, Optional -import time -from src.game_logic import GameManager -from src.settings import ( +from typing import Optional +from pathlib import Path +from .game_logic import GameManager +from .high_scores import HighScores +from .settings import ( WINDOW_SIZE, WINDOW_TITLE, WINDOW_BG, TITLE_FONT, TEXT_FONT, PRIMARY_COLOR ) +import time class TypingSpeedGUI: """Main GUI class for the Typing Speed Test application.""" - def __init__(self, root: tk.Tk): + def __init__(self, root: tk.Tk, word_list_file: Optional[Path] = None, scores_file: Optional[Path] = None): + """Initialize the GUI.""" self.root = root self.root.title(WINDOW_TITLE) self.root.geometry(WINDOW_SIZE) self.root.configure(bg=WINDOW_BG) - self.game = GameManager() - self.timer_id: Optional[str] = None + word_list_path = word_list_file or Path("assets/wordlist.txt") + scores_path = scores_file or Path("data/scores.json") + + self.game = GameManager(word_list_path) + self.high_scores = HighScores(scores_path) + self.current_text = "" + self.typed_chars = 0 + self.timer_id = None - self.create_widgets() - self.setup_bindings() + # Initialize difficulty variable + self.difficulty_var = tk.StringVar(value='medium') + self.difficulty_var.trace_add('write', self._on_difficulty_change) + + self._create_widgets() + self._setup_bindings() - def create_widgets(self) -> None: - """Create and setup all GUI widgets.""" + def _create_widgets(self) -> None: + """Create GUI widgets.""" # Title - title = tk.Label( + self.title_label = ttk.Label( self.root, - text=WINDOW_TITLE, + text="Typing Speed Test", font=TITLE_FONT, - bg=WINDOW_BG, - fg=PRIMARY_COLOR + background=WINDOW_BG, + foreground=PRIMARY_COLOR ) - title.pack(pady=10) + self.title_label.pack(pady=20) - # Difficulty selector - difficulty_frame = tk.Frame(self.root, bg=WINDOW_BG) - difficulty_frame.pack(pady=5) + # Difficulty selection + difficulty_frame = ttk.Frame(self.root) + difficulty_frame.pack(pady=10) - tk.Label( - difficulty_frame, - text="Difficulty:", - font=TEXT_FONT, - bg=WINDOW_BG - ).pack(side=tk.LEFT, padx=5) - - self.difficulty_var = tk.StringVar(value='medium') + ttk.Label(difficulty_frame, text="Difficulty:").pack(side=tk.LEFT, padx=5) for diff in ['easy', 'medium', 'hard']: ttk.Radiobutton( difficulty_frame, text=diff.capitalize(), - value=diff, variable=self.difficulty_var, - command=self.change_difficulty + value=diff ).pack(side=tk.LEFT, padx=5) - # Display text - self.display_text = tk.Text( + # Text display + self.text_display = tk.Text( self.root, height=3, width=50, @@ -68,105 +73,175 @@ def create_widgets(self) -> None: wrap=tk.WORD, state='disabled' ) - self.display_text.pack(pady=20, padx=20) + self.text_display.pack(pady=20) - # Entry field - self.type_entry = tk.Entry( + # Input field + self.input_field = ttk.Entry( self.root, - font=TEXT_FONT, width=50, + font=TEXT_FONT, state='disabled' ) - self.type_entry.pack(pady=10) + self.input_field.pack(pady=10) # Timer label - self.timer_label = tk.Label( + self.timer_label = ttk.Label( self.root, - text="", - font=TEXT_FONT, - bg=WINDOW_BG + text="Time: 0", + font=TEXT_FONT ) - self.timer_label.pack(pady=5) + self.timer_label.pack(pady=10) - # Results label - self.results_label = tk.Label( + # Start button + self.start_button = ttk.Button( self.root, - text="", - font=TEXT_FONT, - bg=WINDOW_BG + text="Start", + command=self.start_game ) - self.results_label.pack(pady=5) + self.start_button.pack(pady=10) - # Start button - self.start_button = tk.Button( - self.root, - text="Start Test", - command=self.start_test, + # Stats frame + stats_frame = ttk.Frame(self.root) + stats_frame.pack(pady=10) + + self.wpm_label = ttk.Label( + stats_frame, + text="0 WPM", font=TEXT_FONT ) - self.start_button.pack(pady=10) - - def setup_bindings(self) -> None: - """Setup keyboard and event bindings.""" - self.type_entry.bind('', lambda e: self.check_completion()) - - def start_test(self) -> None: - """Start a new typing test.""" - self.game.start_game() - self.display_text.config(state='normal') - self.display_text.delete('1.0', tk.END) - self.display_text.insert('1.0', self.game.current_text) - self.display_text.config(state='disabled') + self.wpm_label.pack(side=tk.LEFT, padx=10) - self.type_entry.config(state='normal') - self.type_entry.delete(0, tk.END) - self.type_entry.focus() + self.accuracy_label = ttk.Label( + stats_frame, + text="100%", + font=TEXT_FONT + ) + self.accuracy_label.pack(side=tk.LEFT, padx=10) - self.start_button.config(state='disabled') - self.results_label.config(text="") + # Control buttons + button_frame = ttk.Frame(self.root) + button_frame.pack(pady=20) - if self.game.time_limit: - self.update_timer() + self.reset_button = ttk.Button( + button_frame, + text="Reset", + command=self.reset_game, + state='disabled' + ) + self.reset_button.pack(side=tk.LEFT, padx=5) - def update_timer(self) -> None: - """Update the timer display.""" + def _setup_bindings(self) -> None: + """Setup keyboard bindings.""" + self.input_field.bind('', self.check_progress) + + def start_game(self) -> None: + """Start a new typing test.""" + self.game.start_game(self.difficulty_var.get()) + self.current_text = self.game.current_text + self.text_display.configure(state='normal') + self.text_display.delete('1.0', tk.END) + self.text_display.insert('1.0', self.current_text) + self.text_display.configure(state='disabled') + self.input_field.configure(state='normal') + self.input_field.delete(0, tk.END) + self.start_button.configure(state='disabled') + self.reset_button.configure(state='normal') + self._update_timer() + + def reset_game(self) -> None: + """Reset the game state.""" + self.game.reset() + self.current_text = "" + self.typed_chars = 0 + if self.timer_id: self.root.after_cancel(self.timer_id) + self.timer_id = None + + self.text_display.configure(state='normal') + self.text_display.delete('1.0', tk.END) + self.text_display.configure(state='disabled') + self.input_field.configure(state='disabled') + self.input_field.delete(0, tk.END) + self.start_button.configure(state='normal') + self.reset_button.configure(state='disabled') + self.timer_label.configure(text="Time: 0") + self.wpm_label.configure(text="0 WPM") + self.accuracy_label.configure(text="100%") + + def check_progress(self, event: Optional[tk.Event] = None) -> None: + """Check typing progress.""" + if not self.game.start_time: + return + + typed_text = self.input_field.get() + self.typed_chars = len(typed_text) + + # Update text display colors + self.text_display.configure(state='normal') + self.text_display.delete('1.0', tk.END) + self.text_display.insert('1.0', self.current_text) + + # Color the text based on correctness + for i, (typed_char, correct_char) in enumerate( + zip(typed_text, self.current_text[:len(typed_text)]) + ): + tag = 'correct' if typed_char == correct_char else 'incorrect' + self.text_display.tag_add(tag, f'1.{i}', f'1.{i+1}') + + self.text_display.configure(state='disabled') + + # Update stats + results = self.game.calculate_results(typed_text) + self.wpm_label.configure(text=f"{results['wpm']} WPM") + self.accuracy_label.configure(text=f"{results['accuracy']}%") - remaining_time = self.game.get_remaining_time() - if remaining_time is not None: - self.timer_label.config(text=f"Time remaining: {remaining_time}s") - if remaining_time > 0: - self.timer_id = self.root.after(1000, self.update_timer) - else: - self.end_test() - else: - self.timer_label.config(text="") + # Check if test is complete + if len(typed_text) >= len(self.current_text) or self.game.is_time_up(): + self.end_test() def end_test(self) -> None: - """End the current typing test.""" - typed_text = self.type_entry.get() - results = self.game.calculate_results(typed_text) + """End the typing test.""" + if not self.game.start_time: + return + + results = self.game.calculate_results(self.input_field.get()) + self.high_scores.add_score( + self.game.difficulty, + results['wpm'], + results['accuracy'] + ) - result_text = ( + self.input_field.configure(state='disabled') + self.start_button.configure(state='normal') + self.reset_button.configure(state='disabled') + + messagebox.showinfo( + "Test Complete", f"WPM: {results['wpm']}\n" f"Accuracy: {results['accuracy']}%\n" - f"Time: {results['time']}s" + f"Time: {results['time']} seconds" ) - self.results_label.config(text=result_text) - - self.type_entry.config(state='disabled') - self.start_button.config(state='normal') - if self.timer_id: - self.root.after_cancel(self.timer_id) - def check_completion(self) -> None: - """Check if the typing test is complete.""" - if self.game.is_time_up(): - self.end_test() + def _on_difficulty_change(self, *args) -> None: + """Handle difficulty change.""" + difficulty = self.difficulty_var.get() + self.game.set_difficulty(difficulty) + + def _update_timer(self) -> None: + """Update the timer display.""" + if not self.game.start_time: + return + + elapsed = int(time.time() - self.game.start_time) + self.timer_label.configure(text=f"Time: {elapsed}") + + if not self.game.is_time_up(): + self.timer_id = self.root.after(100, self._update_timer) else: self.end_test() - - def change_difficulty(self) -> None: - """Change the game difficulty.""" - self.game.set_difficulty(self.difficulty_var.get()) + + def destroy(self) -> None: + """Clean up resources.""" + if hasattr(self, 'root'): + self.root.destroy() diff --git a/src/high_scores.py b/src/high_scores.py index a0f3d43..e9717b3 100644 --- a/src/high_scores.py +++ b/src/high_scores.py @@ -1,49 +1,52 @@ -""" -High scores management for the Typing Speed Test application. -""" +"""High scores management.""" import json -from datetime import datetime -from typing import Dict, List, Any -from .settings import SCORES_FILE, MAX_HIGH_SCORES +from pathlib import Path +from typing import Dict, List -class HighScores: - """Manages high scores for the typing speed test.""" - - def __init__(self): - self.scores_file = SCORES_FILE - self.max_scores = MAX_HIGH_SCORES - self.scores = self._create_default_scores() - self._load_scores() +from .settings import MAX_HIGH_SCORES - def _load_scores(self) -> None: - """Load scores from file if it exists.""" - try: - with open(self.scores_file, 'r') as f: - loaded_scores = json.load(f) - # Only load valid scores - for diff in ['easy', 'medium', 'hard']: - if diff in loaded_scores and isinstance(loaded_scores[diff], list): - self.scores[diff] = loaded_scores[diff] - except (json.JSONDecodeError, FileNotFoundError): - pass # Keep default scores +class HighScores: + """Manages high scores.""" - def _create_default_scores(self) -> Dict[str, List[Dict[str, Any]]]: - """Create a new scores dictionary with empty lists.""" - return { + def __init__(self, scores_file: Path): + """Initialize high scores manager.""" + self.scores_file = Path(scores_file) + self.scores: Dict[str, List[Dict[str, float]]] = { 'easy': [], 'medium': [], 'hard': [] } + self._load_scores() + + def _load_scores(self) -> None: + """Load scores from file.""" + if self.scores_file.exists(): + try: + with open(self.scores_file, 'r') as f: + loaded_scores = json.load(f) + # Validate loaded scores format + if isinstance(loaded_scores, dict) and all( + difficulty in loaded_scores for difficulty in ['easy', 'medium', 'hard'] + ): + self.scores = loaded_scores + except (json.JSONDecodeError, KeyError): + # Reset scores if file is corrupted + self._save_scores() + + def _save_scores(self) -> None: + """Save scores to file.""" + with open(self.scores_file, 'w') as f: + json.dump(self.scores, f) def add_score(self, wpm: float, accuracy: float, difficulty: str) -> None: - """Add a new score to the high scores list.""" + """Add a new score.""" if difficulty not in self.scores: - return + raise ValueError(f"Invalid difficulty: {difficulty}") score = { - 'wpm': round(wpm, 2), - 'accuracy': round(accuracy, 2), - 'date': datetime.now().isoformat() + 'wpm': wpm, + 'accuracy': accuracy, + 'timestamp': None # Could add timestamp if needed } # Add score and sort by WPM @@ -51,19 +54,13 @@ def add_score(self, wpm: float, accuracy: float, difficulty: str) -> None: self.scores[difficulty].sort(key=lambda x: x['wpm'], reverse=True) # Keep only top scores - self.scores[difficulty] = self.scores[difficulty][:self.max_scores] + if len(self.scores[difficulty]) > MAX_HIGH_SCORES: + self.scores[difficulty] = self.scores[difficulty][:MAX_HIGH_SCORES] self._save_scores() - def get_scores(self, difficulty: str) -> List[Dict[str, Any]]: - """Get all scores for a specific difficulty.""" - return self.scores.get(difficulty, []) - - def _save_scores(self) -> None: - """Save scores to file.""" - try: - self.scores_file.parent.mkdir(parents=True, exist_ok=True) - with open(self.scores_file, 'w') as f: - json.dump(self.scores, f, indent=4) - except (OSError, IOError) as e: - print(f"Error saving scores: {e}") # In production, use proper logging + def get_scores(self, difficulty: str) -> List[Dict[str, float]]: + """Get scores for a difficulty level.""" + if difficulty not in self.scores: + raise ValueError(f"Invalid difficulty: {difficulty}") + return self.scores[difficulty] diff --git a/src/main.py b/src/main.py index 7e79d64..ccffe5d 100644 --- a/src/main.py +++ b/src/main.py @@ -2,7 +2,7 @@ Main entry point for the Typing Speed Test application. """ import tkinter as tk -from gui import TypingSpeedGUI +from src.gui import TypingSpeedGUI def main(): root = tk.Tk() diff --git a/src/utils.py b/src/utils.py index 96b2b57..d48f8eb 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,28 +1,40 @@ """ -Utility functions for the Typing Speed Test application. +Utility functions for the typing speed test. """ -import json +from pathlib import Path from typing import List -from .settings import WORD_LISTS_FILE -def load_word_list() -> List[str]: - """Load the word list from the assets directory.""" - try: - with open(WORD_LISTS_FILE, 'r') as f: - return json.load(f)['words'] - except FileNotFoundError: - # Fallback to default word list if file doesn't exist - return ["the", "be", "to", "of", "and", "a", "in", "that", "have", "I"] - -def calculate_wpm(total_words: int, time_taken: float) -> float: +def calculate_wpm(typed_text: str, elapsed_time: float) -> float: """Calculate words per minute.""" - if time_taken == 0: - return 0 - return (total_words / time_taken) * 60 + if elapsed_time <= 0: + return 0.0 + + word_count = len(typed_text.split()) + minutes = elapsed_time / 60 + return word_count / minutes if minutes > 0 else 0.0 def calculate_accuracy(typed_text: str, target_text: str) -> float: - """Calculate typing accuracy percentage.""" - if not target_text: - return 0 - correct_chars = sum(1 for t, r in zip(typed_text, target_text) if t == r) - return (correct_chars / len(target_text)) * 100 + """Calculate typing accuracy as a percentage.""" + if not typed_text and not target_text: + return 100.0 + if not typed_text or not target_text: + return 0.0 + + typed_words = typed_text.split() + target_words = target_text.split() + + # Count matching words + correct_words = sum(1 for t, r in zip(typed_words, target_words) if t == r) + total_words = max(len(typed_words), len(target_words)) + + return (correct_words / total_words) * 100.0 + +def load_word_list(word_list_file: Path) -> List[str]: + """Load word list from file.""" + if not Path(word_list_file).exists(): + raise FileNotFoundError(f"Word list file not found: {word_list_file}") + + with open(word_list_file, 'r') as f: + words = [word.strip() for word in f.readlines() if word.strip()] + + return words diff --git a/test_high_scores.py b/test_high_scores.py deleted file mode 100644 index 576e667..0000000 --- a/test_high_scores.py +++ /dev/null @@ -1,87 +0,0 @@ -import unittest -import os -import json -from high_scores import HighScores - -class TestHighScores(unittest.TestCase): - def setUp(self): - self.test_file = "test_scores.json" - # Remove the test file if it exists - if os.path.exists(self.test_file): - os.remove(self.test_file) - self.high_scores = HighScores() - self.high_scores.scores_file = self.test_file - self.high_scores.reset() # Start with clean state - - def tearDown(self): - # Clean up test file - if os.path.exists(self.test_file): - os.remove(self.test_file) - - def test_add_score(self): - """Test adding and sorting scores""" - # Add multiple scores - self.high_scores.add_score("medium", 50.5, 95.0) - self.high_scores.add_score("medium", 60.0, 98.0) - self.high_scores.add_score("medium", 45.0, 92.0) - - # Check scores are sorted by WPM - scores = self.high_scores.get_high_scores("medium") - self.assertEqual(len(scores), 3) - self.assertEqual(scores[0]["wpm"], 60.0) - self.assertEqual(scores[1]["wpm"], 50.5) - self.assertEqual(scores[2]["wpm"], 45.0) - - def test_personal_best(self): - """Test getting personal best score""" - self.high_scores.add_score("easy", 40.0, 90.0) - self.high_scores.add_score("easy", 45.0, 85.0) - - best = self.high_scores.get_personal_best("easy") - self.assertIsNotNone(best) - self.assertEqual(best["wpm"], 45.0) - self.assertEqual(best["accuracy"], 85.0) - - def test_different_difficulties(self): - """Test scores for different difficulties are separate""" - self.high_scores.add_score("easy", 40.0, 90.0) - self.high_scores.add_score("medium", 50.0, 85.0) - self.high_scores.add_score("hard", 30.0, 80.0) - - easy_scores = self.high_scores.get_high_scores("easy") - medium_scores = self.high_scores.get_high_scores("medium") - hard_scores = self.high_scores.get_high_scores("hard") - - self.assertEqual(len(easy_scores), 1) - self.assertEqual(len(medium_scores), 1) - self.assertEqual(len(hard_scores), 1) - - def test_top_10_limit(self): - """Test only top 10 scores are kept""" - # Add 15 scores - for i in range(15): - self.high_scores.add_score("medium", float(i), 90.0) - - scores = self.high_scores.get_high_scores("medium") - self.assertEqual(len(scores), 10) - self.assertEqual(scores[0]["wpm"], 14.0) # Highest score should be kept - - def test_file_persistence(self): - """Test scores are saved to and loaded from file""" - # Add a score and verify it's saved - self.high_scores.add_score("medium", 50.0, 95.0) - self.assertTrue(os.path.exists(self.test_file)) - - # Create new instance and verify score is loaded - new_scores = HighScores() - new_scores.scores_file = self.test_file - new_scores.reset() # Start with clean state - new_scores._load_scores() # Load scores from file - scores = new_scores.get_high_scores("medium") - - self.assertEqual(len(scores), 1) - self.assertEqual(scores[0]["wpm"], 50.0) - self.assertEqual(scores[0]["accuracy"], 95.0) - -if __name__ == '__main__': - unittest.main() diff --git a/test_typing_speed.py b/test_typing_speed.py deleted file mode 100644 index e18aae2..0000000 --- a/test_typing_speed.py +++ /dev/null @@ -1,213 +0,0 @@ -import unittest -from main import TypingSpeedTest -import tkinter as tk -import time - -class TestTypingSpeedTest(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.root = tk.Tk() - cls.app = TypingSpeedTest(cls.root) - - def setUp(self): - """Reset the application state before each test""" - # Clear display text - self.app.display_text.config(state='normal') - self.app.display_text.delete('1.0', tk.END) - self.app.display_text.config(state='disabled') - - # Clear entry field - self.app.type_entry.delete(0, tk.END) - self.app.type_entry.config(state='normal') - - # Reset variables - self.app.current_text = "" - self.app.start_time = None - self.app.word_count = 25 - - # Reset labels and buttons - self.app.results_label.config(text="") - self.app.start_button.config(state='normal') - - def test_initial_state(self): - """Test the initial state of the application""" - self.assertIsNone(self.app.start_time) - self.assertEqual(self.app.word_count, 25) - self.assertEqual(self.app.current_text, "") - - def test_word_list(self): - """Test that word list exists and is not empty""" - self.assertGreater(len(self.app.word_list), 0) - self.assertIsInstance(self.app.word_list, list) - self.assertTrue(all(isinstance(word, str) for word in self.app.word_list)) - - def test_window_properties(self): - """Test window properties""" - self.assertEqual(self.app.root.title(), "Typing Speed Test") - geometry = self.app.root.geometry() - self.assertRegex(geometry, r'\d+x\d+(?:\+\d+\+\d+)?') - - def test_ui_elements(self): - """Test that main UI elements exist""" - self.assertIsInstance(self.app.display_text, tk.Text) - self.assertIsInstance(self.app.text_frame, tk.Frame) - - def test_generate_text(self): - """Test text generation""" - text = self.app.generate_text() - self.assertIsInstance(text, str) - self.assertEqual(len(text.split()), self.app.word_count) - # Verify all words are from word_list - for word in text.split(): - self.assertIn(word, self.app.word_list) - - def test_start_test(self): - """Test starting a new test""" - # Clear the entry field first - self.app.type_entry.delete(0, tk.END) - - self.app.start_test() - self.assertIsNotNone(self.app.start_time) - self.assertNotEqual(self.app.current_text, "") - self.assertEqual(self.app.type_entry.get(), "") - self.assertEqual(self.app.results_label.cget("text"), "") - self.assertEqual(self.app.start_button.cget("state"), "disabled") - self.assertEqual(self.app.type_entry.cget("state"), "normal") - - def test_check_progress_accuracy(self): - """Test accuracy calculation during typing""" - self.app.start_test() - self.app.current_text = "test word" - - # Test perfect typing - self.app.type_entry.insert(0, "test") - self.app.check_progress(None) - self.assertIn("Accuracy: 100.0%", self.app.results_label.cget("text")) - - # Test imperfect typing - self.app.type_entry.delete(0, tk.END) - self.app.type_entry.insert(0, "tast") - self.app.check_progress(None) - self.assertIn("Accuracy: 75.0%", self.app.results_label.cget("text")) - - def test_end_test(self): - """Test ending the test""" - self.app.start_test() - self.app.start_time = time.time() - 60 # Simulate 60 seconds passed - self.app.current_text = "test word" # 2 words - - self.app.end_test(100.0) # 100% accuracy - - result_text = self.app.results_label.cget("text") - self.assertIn("Time: 60", result_text) - self.assertIn("WPM: 2.0", result_text) # 2 words in 60 seconds = 2 WPM - self.assertIn("Accuracy: 100.0%", result_text) - self.assertEqual(self.app.type_entry.cget("state"), "disabled") - self.assertEqual(self.app.start_button.cget("state"), "normal") - - def test_empty_input(self): - """Test handling of empty input""" - self.app.start_test() - self.app.current_text = "test word" - - self.app.type_entry.delete(0, tk.END) - self.app.check_progress(None) - - # Empty input should show 0 WPM and 0% accuracy - result_text = self.app.results_label.cget("text") - self.assertIn("WPM: 0.0", result_text) - self.assertIn("Accuracy: 0.0%", result_text) - - def test_text_coloring(self): - """Test text coloring for correct, incorrect, and remaining text""" - self.app.start_test() - self.app.current_text = "test word" - - # Test correct typing - self.app.type_entry.insert(0, "test") - self.app.check_progress(None) - - # Get all text with tags - text_content = self.app.display_text.get("1.0", tk.END).strip() - - # Verify correct characters are green - correct_ranges = self.app.display_text.tag_ranges("correct") - correct_text = "" - for i in range(0, len(correct_ranges), 2): - correct_text += self.app.display_text.get(correct_ranges[i], correct_ranges[i+1]) - self.assertEqual(correct_text, "test") - - # Verify remaining text is black - remaining_ranges = self.app.display_text.tag_ranges("remaining") - remaining_text = "" - for i in range(0, len(remaining_ranges), 2): - remaining_text += self.app.display_text.get(remaining_ranges[i], remaining_ranges[i+1]) - self.assertEqual(remaining_text.strip(), "word") - - def test_real_time_wpm(self): - """Test real-time WPM calculation""" - self.app.start_test() - self.app.current_text = "test word example" - self.app.start_time = time.time() - 30 # Simulate 30 seconds passed - - # Type two words - self.app.type_entry.delete(0, tk.END) # Clear any existing text - self.app.type_entry.insert(0, "test word") - self.app.check_progress(None) - - # Check WPM (2 words in 30 seconds = 4 WPM) - result_text = self.app.results_label.cget("text") - wpm = float(result_text.split("WPM: ")[1].split(" |")[0]) - self.assertGreater(wpm, 0) # Just verify WPM is being calculated - self.assertLess(wpm, 180) # And is within reasonable bounds - - def test_long_input(self): - """Test handling of input longer than target text""" - self.app.start_test() - self.app.current_text = "test" - - # Type more characters than target - self.app.type_entry.insert(0, "testing") - self.app.check_progress(None) - - # Should automatically end test - self.assertEqual(self.app.type_entry.cget("state"), "disabled") - self.assertEqual(self.app.start_button.cget("state"), "normal") - - def test_special_characters(self): - """Test handling of special characters in input""" - self.app.start_test() - self.app.current_text = "test" - self.app.start_time = time.time() # Reset start time - - # Type text with special characters - self.app.type_entry.delete(0, tk.END) - self.app.type_entry.insert(0, "te$t") - self.app.check_progress(None) - - # Should handle special characters correctly (t and e are correct, $ and t are wrong) - result_text = self.app.results_label.cget("text") - accuracy = float(result_text.split("Accuracy: ")[1].split("%")[0]) - self.assertEqual(accuracy, 75.0) # 't', 'e', and 't' are correct out of 4 characters - - def test_rapid_typing(self): - """Test handling of rapid typing""" - self.app.start_test() - self.app.current_text = "test word example" - self.app.start_time = time.time() - 1 # Simulate 1 second passed - - # Simulate very fast typing - self.app.type_entry.insert(0, "test word example") - self.app.check_progress(None) - - # Check high WPM is calculated correctly (3 words in 1 second = 180 WPM) - result_text = self.app.results_label.cget("text") - wpm = float(result_text.split("WPM: ")[1].split(" |")[0]) - self.assertGreater(wpm, 150) # Should be around 180 WPM - - @classmethod - def tearDownClass(cls): - cls.root.destroy() - -if __name__ == '__main__': - unittest.main() diff --git a/tests/conftest.py b/tests/conftest.py index 43bc7a5..aeeee4c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,81 +1,78 @@ """ -Pytest configuration and fixtures. +Test configuration and fixtures. """ import os -import tempfile -from pathlib import Path +import json import pytest import tkinter as tk +from pathlib import Path +from xvfbwrapper import Xvfb from .test_helpers import ( - create_test_scores_file, - create_test_word_list, get_sample_scores, get_sample_words ) -def pytest_configure(config): - """Configure pytest with custom markers.""" - config.addinivalue_line( - "markers", "gui: mark test as requiring GUI" - ) +# Constants +MAX_HIGH_SCORES = 10 @pytest.fixture(scope="session") def xvfb(): - """Setup virtual display if running in CI.""" - if os.environ.get('CI'): - try: - from xvfbwrapper import Xvfb - vdisplay = Xvfb() - vdisplay.start() - yield vdisplay - vdisplay.stop() - except ImportError: - pytest.skip("xvfbwrapper not installed") - else: - yield None + """Start virtual display for GUI tests.""" + try: + vdisplay = Xvfb(width=1280, height=720, colordepth=24) + vdisplay.start() + yield vdisplay + vdisplay.stop() + except (EnvironmentError, OSError) as e: + pytest.skip(f"Xvfb not available: {e}") @pytest.fixture def tk_root(xvfb): - """Create a Tkinter root window for tests.""" - root = tk.Tk() - yield root - root.destroy() + """Create Tkinter root window.""" + root = None + try: + root = tk.Tk() + root.geometry("800x600") # Set a fixed size for consistency + yield root + except tk.TclError as e: + pytest.skip(f"Could not create Tkinter window: {e}") + finally: + if root: + try: + root.destroy() + except tk.TclError: + pass # Window might already be destroyed + +@pytest.fixture +def temp_dir(tmp_path): + """Create temporary directory for test files.""" + return tmp_path @pytest.fixture -def temp_dir(): - """Create a temporary directory for test files.""" - with tempfile.TemporaryDirectory() as tmpdirname: - yield Path(tmpdirname) +def test_word_list_file(temp_dir): + """Create test word list file.""" + word_list = get_sample_words() + word_list_file = temp_dir / "test_words.txt" + word_list_file.write_text("\n".join(word_list)) + return word_list_file @pytest.fixture def test_scores_file(temp_dir): - """Create a temporary scores file with sample data.""" + """Create test scores file.""" + scores = get_sample_scores() scores_file = temp_dir / "test_scores.json" - create_test_scores_file(scores_file, get_sample_scores()) + scores_file.write_text(json.dumps(scores)) return scores_file @pytest.fixture -def test_word_list_file(temp_dir): - """Create a temporary word list file with sample words.""" - word_list_file = temp_dir / "test_words.json" - create_test_word_list(word_list_file, get_sample_words()) - return word_list_file - -@pytest.fixture -def test_env(monkeypatch): +def test_env(): """Set up test environment variables.""" - env_vars = { + os.environ['APP_ENV'] = 'test' + os.environ['MAX_HIGH_SCORES'] = str(MAX_HIGH_SCORES) + return { 'APP_ENV': 'test', - 'MAX_HIGH_SCORES': '5', - 'WINDOW_SIZE': '800x600', - 'WINDOW_TITLE': 'Typing Speed Test', - 'PRIMARY_COLOR': '#2C3E50', - 'SECONDARY_COLOR': '#ECF0F1', - 'ACCENT_COLOR': '#3498DB' + 'MAX_HIGH_SCORES': MAX_HIGH_SCORES } - for key, value in env_vars.items(): - monkeypatch.setenv(key, value) - return env_vars @pytest.fixture def mock_config(test_env, test_scores_file, test_word_list_file): @@ -83,13 +80,6 @@ def mock_config(test_env, test_scores_file, test_word_list_file): return { 'env': test_env['APP_ENV'], 'max_high_scores': int(test_env['MAX_HIGH_SCORES']), - 'window_size': test_env['WINDOW_SIZE'], - 'window_title': test_env['WINDOW_TITLE'], - 'colors': { - 'primary': test_env['PRIMARY_COLOR'], - 'secondary': test_env['SECONDARY_COLOR'], - 'accent': test_env['ACCENT_COLOR'] - }, 'files': { 'scores': str(test_scores_file), 'words': str(test_word_list_file) diff --git a/tests/test_game_logic.py b/tests/test_game_logic.py index 38f9e3f..8bfaad2 100644 --- a/tests/test_game_logic.py +++ b/tests/test_game_logic.py @@ -1,31 +1,33 @@ -""" -Tests for game logic functionality. -""" +"""Tests for game logic functionality.""" import time import pytest from src.game_logic import GameManager -from src.settings import DIFFICULTIES @pytest.fixture -def game_manager(): +def game_manager(test_word_list_file): """Fixture for GameManager instance.""" - return GameManager() + manager = GameManager(test_word_list_file) + manager.word_count = 3 # Use smaller word count for testing + return manager def test_game_manager_initialization(game_manager): """Test GameManager initialization.""" - assert game_manager.current_difficulty == 'medium' - assert game_manager.word_count == DIFFICULTIES['medium']['words'] - assert game_manager.time_limit == DIFFICULTIES['medium']['time_limit'] + assert game_manager.word_list + assert game_manager.current_text == "" + assert game_manager.start_time is None + assert game_manager.difficulty == 'medium' def test_generate_text(game_manager): """Test text generation.""" text = game_manager.generate_text() assert isinstance(text, str) assert len(text.split()) == game_manager.word_count + assert text == game_manager.current_text def test_difficulty_settings(game_manager): """Test difficulty changes.""" # Test medium difficulty (default) + game_manager.set_difficulty('medium') assert game_manager.word_count == 25 assert game_manager.time_limit == 60 @@ -37,45 +39,44 @@ def test_difficulty_settings(game_manager): # Test hard difficulty game_manager.set_difficulty('hard') assert game_manager.word_count == 40 - assert game_manager.time_limit == 60 + assert game_manager.time_limit == 45 def test_calculate_results(game_manager): """Test results calculation.""" - game_manager.current_text = "test text" + test_text = "test text" game_manager.start_game() - + game_manager.current_text = test_text # Set current_text after start_game to avoid it being overwritten + # Wait a bit to simulate typing time time.sleep(0.1) - + # Simulate typing with exact match - results = game_manager.calculate_results("test text") - + results = game_manager.calculate_results(test_text) + assert isinstance(results, dict) assert 'wpm' in results assert 'accuracy' in results assert 'time' in results assert results['accuracy'] == 100.0 - - # Test with partial match - game_manager.current_text = "test text again" - game_manager.start_game() - time.sleep(0.1) - results = game_manager.calculate_results("test text wrong") - assert results['accuracy'] < 100.0 + assert results['wpm'] > 0 + assert results['time'] > 0 def test_time_management(game_manager): """Test time-related functions.""" + game_manager.word_count = 3 # Use smaller word count for testing game_manager.start_game() assert game_manager.start_time is not None - + time.sleep(0.1) elapsed = game_manager.get_elapsed_time() assert elapsed > 0 - + # Test time limit - game_manager.set_difficulty('medium') # 60 second limit + game_manager.set_difficulty('easy') # 120 seconds assert not game_manager.is_time_up() - - # Test no time limit - game_manager.set_difficulty('easy') # No time limit + + # Reset and test with very short time + game_manager.reset() + game_manager.set_difficulty('hard') # 45 seconds + game_manager.start_game() assert not game_manager.is_time_up() diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..6b61c25 --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,99 @@ +""" +Tests for GUI functionality. +""" +import time +import pytest +import tkinter as tk +from tkinter import ttk +from unittest.mock import patch, MagicMock +from src.gui import TypingSpeedGUI + +@pytest.fixture +def typing_gui(tk_root, test_word_list_file, test_scores_file): + """Fixture for TypingSpeedGUI instance.""" + gui = None + try: + gui = TypingSpeedGUI(tk_root, test_word_list_file, test_scores_file) + yield gui + finally: + if gui: + try: + gui.destroy() + except tk.TclError: + pass + +def test_gui_initialization(typing_gui): + """Test GUI initialization.""" + assert isinstance(typing_gui.root, tk.Tk) + assert isinstance(typing_gui.text_display, tk.Text) + assert isinstance(typing_gui.input_field, ttk.Entry) + assert isinstance(typing_gui.timer_label, ttk.Label) + assert isinstance(typing_gui.start_button, ttk.Button) + assert typing_gui.difficulty_var.get() == 'medium' + +def test_difficulty_change(typing_gui): + """Test difficulty selection.""" + typing_gui.difficulty_var.set('easy') + typing_gui.root.update() # Process events + assert typing_gui.game.difficulty == 'easy' + assert typing_gui.game.word_count == 15 + + typing_gui.difficulty_var.set('hard') + typing_gui.root.update() # Process events + assert typing_gui.game.difficulty == 'hard' + assert typing_gui.game.word_count == 40 + +def test_start_game(typing_gui): + """Test game start functionality.""" + typing_gui.start_game() + typing_gui.root.update() # Process events + + assert typing_gui.game.current_text + assert typing_gui.text_display.get("1.0", tk.END).strip() + assert str(typing_gui.input_field.cget('state')) == 'normal' + assert str(typing_gui.start_button.cget('state')) == 'disabled' + +def test_reset_game(typing_gui): + """Test game reset functionality.""" + typing_gui.start_game() + typing_gui.root.update() # Process events + + typing_gui.reset_game() + typing_gui.root.update() # Process events + + assert not typing_gui.game.current_text + assert not typing_gui.text_display.get("1.0", tk.END).strip() + assert str(typing_gui.input_field.cget('state')) == 'disabled' + assert str(typing_gui.start_button.cget('state')) == 'normal' + assert typing_gui.timer_label.cget('text') == "Time: 0" + +def test_input_validation(typing_gui): + """Test input field validation.""" + typing_gui.start_game() + typing_gui.root.update() # Process events + + # Simulate correct input + typing_gui.input_field.insert(tk.END, "test") + typing_gui.root.update() # Process events + assert typing_gui.input_field.get().strip() == "test" + + # Clear input + typing_gui.input_field.delete(0, tk.END) + typing_gui.root.update() # Process events + assert not typing_gui.input_field.get().strip() + +def test_timer_update(typing_gui): + """Test timer updates.""" + # Set up initial game state + typing_gui.game.start_time = time.time() - 5 # Started 5 seconds ago + + # Update timer display + typing_gui._update_timer() + typing_gui.root.update() # Process events + + # Verify timer display + timer_text = typing_gui.timer_label.cget('text') + assert timer_text == "Time: 5", f"Expected 'Time: 5' but got '{timer_text}'" + + # Verify timer is scheduled + assert typing_gui.timer_id is not None diff --git a/tests/test_high_scores.py b/tests/test_high_scores.py index fe3fea8..cd7726b 100644 --- a/tests/test_high_scores.py +++ b/tests/test_high_scores.py @@ -1,17 +1,17 @@ """ Tests for high scores functionality. """ -import pytest import json -from pathlib import Path +import pytest from src.high_scores import HighScores -from src.settings import MAX_HIGH_SCORES + +MAX_HIGH_SCORES = 10 @pytest.fixture def high_scores(temp_dir): - """Fixture for HighScores instance with test file.""" + """Fixture for HighScores instance.""" + # Initialize empty scores file scores_file = temp_dir / "test_scores.json" - # Initialize with empty scores structure scores_data = { 'easy': [], 'medium': [], @@ -19,14 +19,13 @@ def high_scores(temp_dir): } scores_file.write_text(json.dumps(scores_data)) - hs = HighScores(scores_file) - return hs + return HighScores(scores_file) def test_initialization(high_scores): """Test HighScores initialization.""" + assert high_scores.scores_file assert isinstance(high_scores.scores, dict) - assert all(diff in high_scores.scores for diff in ['easy', 'medium', 'hard']) - assert all(isinstance(high_scores.scores[diff], list) for diff in ['easy', 'medium', 'hard']) + assert all(difficulty in high_scores.scores for difficulty in ['easy', 'medium', 'hard']) def test_add_score(high_scores): """Test adding and sorting scores.""" @@ -37,9 +36,8 @@ def test_add_score(high_scores): scores = high_scores.get_scores("medium") assert len(scores) == 3 - assert scores[0]['wpm'] == 60.0 - assert scores[0]['accuracy'] == 98.0 - assert scores[-1]['wpm'] == 45.0 + assert scores[0]['wpm'] == 60.0 # Highest WPM first + assert scores[-1]['wpm'] == 45.0 # Lowest WPM last def test_max_scores_limit(high_scores): """Test that scores list respects max_scores limit.""" @@ -49,22 +47,42 @@ def test_max_scores_limit(high_scores): scores = high_scores.get_scores("medium") assert len(scores) == MAX_HIGH_SCORES - assert scores[0]['wpm'] == 50.0 + (MAX_HIGH_SCORES + 4) # Highest score + assert scores[0]['wpm'] == 64.0 # Highest WPM first (50 + 14) + assert scores[-1]['wpm'] == 55.0 # Only keeps top MAX_HIGH_SCORES (50 + 5) -def test_score_persistence(high_scores): +def test_score_persistence(high_scores, temp_dir): """Test that scores are properly saved and loaded.""" # Add some scores - high_scores.add_score(55.0, 96.0, "medium") - high_scores.add_score(65.0, 98.0, "hard") + high_scores.add_score(50.5, 95.0, "medium") + high_scores.add_score(60.0, 98.0, "medium") # Create new instance with same file - new_scores = HighScores(high_scores.scores_file) + scores_file = temp_dir / "test_scores.json" + new_high_scores = HighScores(scores_file) # Verify scores were loaded - assert new_scores.get_scores("medium")[0]['wpm'] == 55.0 - assert new_scores.get_scores("hard")[0]['wpm'] == 65.0 + scores = new_high_scores.get_scores("medium") + assert len(scores) == 2 + assert scores[0]['wpm'] == 60.0 + assert scores[1]['wpm'] == 50.5 def test_invalid_difficulty(high_scores): """Test handling of invalid difficulty levels.""" - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid difficulty"): high_scores.add_score(50.0, 95.0, "invalid") + +def test_empty_scores_file(temp_dir): + """Test initialization with empty scores file.""" + scores_file = temp_dir / "empty_scores.json" + high_scores = HighScores(scores_file) + assert all(difficulty in high_scores.scores for difficulty in ['easy', 'medium', 'hard']) + assert all(len(scores) == 0 for scores in high_scores.scores.values()) + +def test_corrupted_scores_file(temp_dir): + """Test handling of corrupted scores file.""" + scores_file = temp_dir / "corrupted_scores.json" + scores_file.write_text("invalid json") + + high_scores = HighScores(scores_file) + assert all(difficulty in high_scores.scores for difficulty in ['easy', 'medium', 'hard']) + assert all(len(scores) == 0 for scores in high_scores.scores.values()) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..990ae66 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,20 @@ +"""Tests for main entry point.""" +import tkinter as tk +import pytest +from unittest.mock import patch, MagicMock +from src.main import main + +def test_main(): + """Test main function.""" + # Mock tkinter.Tk + mock_root = MagicMock() + mock_root.mainloop = MagicMock() + + with patch('tkinter.Tk', return_value=mock_root), \ + patch('src.main.TypingSpeedGUI') as mock_gui: + # Run main + main() + + # Verify + mock_gui.assert_called_once_with(mock_root) + mock_root.mainloop.assert_called_once() diff --git a/tests/test_typing_speed.py b/tests/test_typing_speed.py deleted file mode 100644 index eae47ec..0000000 --- a/tests/test_typing_speed.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -Tests for the typing speed GUI functionality. -""" -import pytest -import tkinter as tk -from main import TypingSpeedTest - -@pytest.fixture -def gui(tk_root): - """Fixture for TypingSpeedTest instance.""" - app = TypingSpeedTest(tk_root) - yield app - # Cleanup is handled by tk_root fixture in conftest.py - -def test_gui_initialization(gui): - """Test GUI initialization.""" - assert isinstance(gui.word_count, int) - assert gui.type_entry.cget('state') == 'disabled' - assert gui.start_button.cget('state') == 'normal' - -def test_start_test(gui): - """Test starting a new typing test.""" - gui.start_test() - - assert gui.type_entry.cget('state') == 'normal' - assert gui.start_button.cget('state') == 'disabled' - assert gui.display_text.get('1.0', tk.END).strip() != '' - assert gui.results_label.cget('text') == '' - -def test_end_test(gui): - """Test ending a typing test.""" - gui.start_test() - gui.type_entry.insert(0, "test typing") - gui.end_test(100.0) - - assert gui.type_entry.cget('state') == 'disabled' - assert gui.start_button.cget('state') == 'normal' - assert gui.results_label.cget('text') != '' - -def test_check_progress(gui): - """Test checking progress during typing.""" - gui.start_test() - gui.type_entry.insert(0, "test") - gui.check_progress(None) - - assert gui.results_label.cget('text') != '' - -def test_empty_input(gui): - """Test handling of empty input.""" - gui.start_test() - gui.type_entry.delete(0, tk.END) - gui.check_progress(None) - - assert gui.results_label.cget('text') != '' - -def test_text_coloring(gui): - """Test text coloring for correct, incorrect, and remaining text.""" - gui.start_test() - gui.type_entry.insert(0, "test") - gui.check_progress(None) - - assert gui.display_text.get("1.0", tk.END).strip() != '' - -def test_real_time_wpm(gui): - """Test real-time WPM calculation.""" - gui.start_test() - gui.type_entry.insert(0, "test word") - gui.check_progress(None) - - assert gui.results_label.cget('text') != '' - -def test_long_input(gui): - """Test handling of input longer than target text.""" - gui.start_test() - gui.type_entry.insert(0, "testing") - gui.check_progress(None) - - assert gui.type_entry.cget('state') == 'disabled' - assert gui.start_button.cget('state') == 'normal' - -def test_special_characters(gui): - """Test handling of special characters in input.""" - gui.start_test() - gui.type_entry.insert(0, "te$t") - gui.check_progress(None) - - assert gui.results_label.cget('text') != '' - -def test_rapid_typing(gui): - """Test handling of rapid typing.""" - gui.start_test() - gui.type_entry.insert(0, "test word example") - gui.check_progress(None) - - assert gui.results_label.cget('text') != '' diff --git a/tests/test_utils.py b/tests/test_utils.py index 8143e8d..40f55dd 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,41 +1,51 @@ -""" -Tests for utility functions. -""" +"""Tests for utility functions.""" +import os import pytest from src.utils import calculate_wpm, calculate_accuracy, load_word_list def test_calculate_wpm(): """Test WPM calculation.""" - assert calculate_wpm(30, 60) == 30.0 # 30 words in 1 minute = 30 WPM - assert calculate_wpm(15, 30) == 30.0 # 15 words in 30 seconds = 30 WPM - assert calculate_wpm(0, 60) == 0.0 # No words = 0 WPM - assert calculate_wpm(10, 0) == 0.0 # Divide by zero protection + # Test normal case + assert calculate_wpm("word1 word2 word3", 60) == 3.0 # 3 words in 1 minute = 3 WPM + assert calculate_wpm("word1 word2", 30) == 4.0 # 2 words in 0.5 minutes = 4 WPM + + # Test edge cases + assert calculate_wpm("", 60) == 0.0 # Empty text + assert calculate_wpm("word", 0) == 0.0 # Zero time + assert calculate_wpm("word", -1) == 0.0 # Negative time def test_calculate_accuracy(): """Test accuracy calculation.""" - # Perfect match - assert calculate_accuracy("test", "test") == 100.0 + # Test perfect match + assert calculate_accuracy("test text", "test text") == 100.0 - # No match - assert calculate_accuracy("test", "none") == 0.0 + # Test partial match + assert calculate_accuracy("test text", "test") == 50.0 + assert calculate_accuracy("test text", "test texting") == 50.0 - # Partial match - assert calculate_accuracy("test", "tent") == 75.0 # 3/4 characters match + # Test no match + assert calculate_accuracy("test text", "wrong words") == 0.0 - # Empty strings - assert calculate_accuracy("", "") == 0.0 + # Test empty strings + assert calculate_accuracy("", "") == 100.0 assert calculate_accuracy("test", "") == 0.0 assert calculate_accuracy("", "test") == 0.0 -def test_load_word_list(test_data_dir): +def test_load_word_list(test_word_list_file): """Test word list loading.""" - # Test actual word list loading - words = load_word_list() + # Test loading from file + words = load_word_list(test_word_list_file) assert isinstance(words, list) - assert all(isinstance(word, str) for word in words) assert len(words) > 0 - - # Test with missing file (should return default list) - default_words = load_word_list() - assert isinstance(default_words, list) - assert len(default_words) > 0 + assert all(isinstance(word, str) for word in words) + + # Test with non-existent file + with pytest.raises(FileNotFoundError): + load_word_list("nonexistent.txt") + + # Test with empty file + empty_file = os.path.join(os.path.dirname(test_word_list_file), "empty.txt") + with open(empty_file, "w") as f: + pass + words = load_word_list(empty_file) + assert len(words) == 0