diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..42f7910 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + quality: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint with ruff + run: ruff check src/ tests/ + + - name: Check formatting with ruff + run: ruff format --check src/ tests/ + + - name: Type check with mypy + run: mypy src/ tests/ + + - name: Test with pytest + run: pytest --cov=src --cov-report=term-missing \ No newline at end of file diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml deleted file mode 100644 index 5e77f08..0000000 --- a/.github/workflows/python-package.yml +++ /dev/null @@ -1,40 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Python package - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - build: - - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12"] - - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pytest diff --git a/.gitignore b/.gitignore index 647fe2e..54d029a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,16 @@ __pycache__/ dist/ build/ *.spec +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Virtual environment +.venv/ # User data (local, not tracked) data/times.json +data/.welcome_shown # IDE .vscode/ @@ -18,4 +25,4 @@ data/times.json .DS_Store Thumbs.db -3x3_times.json.bak \ No newline at end of file +3x3_times.json.bak diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..347fd8f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + args: [--config-file=pyproject.toml] + additional_dependencies: [customtkinter>=5.2.2] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + - id: check-merge-conflict + - id: detect-private-key \ No newline at end of file diff --git a/3x3.py b/3x3.py deleted file mode 100644 index 6c7a2cf..0000000 --- a/3x3.py +++ /dev/null @@ -1,603 +0,0 @@ -# This is an all-in-one copy-pastable code file for quickly copying the whole codebase as one .py file -# Copy all the contents from this file and run the code; all libraries are built in with Python -# make sure you have Python 3.10+ - -import copy -import tkinter as tk -from tkinter import messagebox -import time -import json -import os -import sys -import random -from datetime import datetime -import tempfile -import shutil - -MOVES = ["U", "D", "L", "R", "F", "B"] -MODIFIERS = ["", "'", "2"] - -# Face indices: U=0, D=1, F=2, B=3, L=4, R=5 -# Each face: 9 stickers [0..8] in reading order (top-left to bottom-right) -# Standard Western Color Scheme -FACE_COLORS = { - "U": "#ffffff", # white - "D": "#ffff00", # yellow - "F": "#00aa00", # green - "B": "#0055ff", # blue - "L": "#ff8800", # orange - "R": "#ff0000", # red -} - -def make_solved_cube(): - return {f: [f]*9 for f in "UDFBLR"} - -def rotate_face_cw(cube, f): - s = cube[f] - cube[f] = [s[6],s[3],s[0], s[7],s[4],s[1], s[8],s[5],s[2]] - -def rotate_face_ccw(cube, f): - rotate_face_cw(cube, f) - rotate_face_cw(cube, f) - rotate_face_cw(cube, f) - -def apply_move(cube, move): - face = move[0] - mod = move[1:] - times = 3 if mod == "'" else (2 if mod == "2" else 1) - for _ in range(times): - _do_move(cube, face) - -def _do_move(cube, face): - rotate_face_cw(cube, face) - U,D,F,B,L,R = (cube[x] for x in "UDFBLR") - - if face == "U": - # CW from above: L-top -> B-top -> R-top -> F-top -> L-top - tmp = [F[0],F[1],F[2]] - F[0],F[1],F[2] = R[0],R[1],R[2] - R[0],R[1],R[2] = B[0],B[1],B[2] - B[0],B[1],B[2] = L[0],L[1],L[2] - L[0],L[1],L[2] = tmp - elif face == "D": - # CW from below: L-bot -> F-bot -> R-bot -> B-bot -> L-bot - tmp = [F[6],F[7],F[8]] - F[6],F[7],F[8] = L[6],L[7],L[8] - L[6],L[7],L[8] = B[6],B[7],B[8] - B[6],B[7],B[8] = R[6],R[7],R[8] - R[6],R[7],R[8] = tmp - elif face == "F": - # CW from front: U-bot -> R-left -> D-top -> L-right -> U-bot - tmp = [U[6],U[7],U[8]] - U[6],U[7],U[8] = L[8],L[5],L[2] - L[2],L[5],L[8] = D[0],D[1],D[2] - D[0],D[1],D[2] = R[6],R[3],R[0] - R[0],R[3],R[6] = tmp - elif face == "B": - # CW from back: U-top -> L-left -> D-bot -> R-right -> U-top - tmp = [U[0],U[1],U[2]] - U[0],U[1],U[2] = R[2],R[5],R[8] - R[2],R[5],R[8] = D[8],D[7],D[6] - D[6],D[7],D[8] = L[0],L[3],L[6] - L[0],L[3],L[6] = tmp[::-1] - elif face == "L": - # CW from left: U-left -> F-left -> D-left -> B-right -> U-left - tmp = [U[0],U[3],U[6]] - U[0],U[3],U[6] = B[8],B[5],B[2] - B[2],B[5],B[8] = D[6],D[3],D[0] - D[0],D[3],D[6] = F[0],F[3],F[6] - F[0],F[3],F[6] = tmp - elif face == "R": - # CW from right: U-right -> B-left -> D-right -> F-right -> U-right - tmp = [U[2],U[5],U[8]] - U[2],U[5],U[8] = F[2],F[5],F[8] - F[2],F[5],F[8] = D[2],D[5],D[8] - D[2],D[5],D[8] = B[6],B[3],B[0] - B[0],B[3],B[6] = tmp[::-1] - -def open_visualizer(parent, scramble_str): - moves = scramble_str.split() - win = tk.Toplevel(parent) - win.title("Scramble Visualizer") - win.configure(bg="#1a1a1a") - win.geometry("600x540") - - move_label = tk.Label(win, text="", font=("Arial", 16, "bold"), - bg="#1a1a1a", fg="#ff9900") - move_label.pack(pady=(12, 2)) - - seq_label = tk.Label(win, text="", font=("Courier", 11), - bg="#1a1a1a", fg="#555555", wraplength=560, justify="center") - seq_label.pack(pady=(0, 4)) - - nav_label = tk.Label(win, text="\u2190 \u2192 arrow keys to step through moves", - font=("Arial", 10), bg="#1a1a1a", fg="#666666") - nav_label.pack(pady=(0, 6)) - - canvas = tk.Canvas(win, bg="#1a1a1a", highlightthickness=0, width=600, height=340) - canvas.pack() - - def shade(color, factor): - r = int(int(color[1:3], 16) * factor) - g = int(int(color[3:5], 16) * factor) - b = int(int(color[5:7], 16) * factor) - return f"#{r:02x}{g:02x}{b:02x}" - - def draw_cube(cube): - canvas.delete("all") - - # Sizing and center point where U, F, and R faces meet - S = 36 - S2 = 18 - CX, CY = 300, 220 - - def add(p1, p2): return (p1[0] + p2[0], p1[1] + p2[1]) - def scale(v, f): return (v[0] * f, v[1] * f) - - # Isometric basis vectors - vec_DL = (-S, S2) # Down-Left - vec_UR = (S, -S2) # Up-Right - vec_UL = (-S, -S2) # Up-Left - vec_DR = (S, S2) # Down-Right - vec_D = (0, S) # Down - - def draw_face(face_name, orig, vec_c, vec_r, color_darken): - for row in range(3): - for col in range(3): - idx = row * 3 + col - # Calculate 4 corners of the polygon sticker - p0 = add(orig, add(scale(vec_c, col), scale(vec_r, row))) - p1 = add(p0, vec_c) - p2 = add(p1, vec_r) - p3 = add(p0, vec_r) - - color = FACE_COLORS[cube[face_name][idx]] - if color_darken != 1.0: - color = shade(color, color_darken) - - canvas.create_polygon([p0, p1, p2, p3], fill=color, outline="#111", width=2) - - # R face (Right side, Red) - R_orig = (CX, CY) - draw_face("R", R_orig, vec_UR, vec_D, 0.75) - - # F face (Front side, drawn on Left, Green) - F_orig = add((CX, CY), scale(vec_UL, 3)) - draw_face("F", F_orig, vec_DR, vec_D, 0.88) - - # U face (Top side, White) - U_orig = (CX, CY - 3 * S) - draw_face("U", U_orig, vec_DR, vec_DL, 1.0) - - # Pre-compute all states - cube_states = [make_solved_cube()] - for m in moves: - nxt = copy.deepcopy(cube_states[-1]) - apply_move(nxt, m) - cube_states.append(nxt) - - step_idx = [0] - - def refresh(): - idx = step_idx[0] - draw_cube(cube_states[idx]) - if idx == 0: - move_label.config(text="Solved state", fg="#aaaaaa") - seq_label.config(text=" ".join(moves)) - elif idx == len(moves): - move_label.config(text="Done!", fg="#00ff00") - seq_label.config(text=" ".join(moves)) - else: - move_label.config(text=f"Move {idx}/{len(moves)}: {moves[idx-1]}", fg="#ff9900") - parts = [f"[{m}]" if i == idx-1 else m for i, m in enumerate(moves)] - seq_label.config(text=" ".join(parts)) - - def on_key(event): - if event.keysym == "Right" and step_idx[0] < len(moves): - step_idx[0] += 1 - refresh() - elif event.keysym == "Left" and step_idx[0] > 0: - step_idx[0] -= 1 - refresh() - - win.bind("", on_key) - refresh() - -def generate_scramble(length=20): - scramble = [] - last = None - for _ in range(length): - available = [m for m in MOVES if m != last] - move = random.choice(available) - mod = random.choice(MODIFIERS) - scramble.append(move + mod) - last = move - return " ".join(scramble) - -def resource_path(relative_path): - base = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) - return os.path.join(base, relative_path) - -def data_path(relative_path): - base = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__)) - return os.path.join(base, relative_path) - -class SpeedCubeTimer: - def __init__(self, root): - self.root = root - self.root.title("3x3 Speed Cube Timer") - self.root.configure(bg="#1a1a1a") - - try: - self.root.state("zoomed") - except tk.TclError: - self.root.geometry("1024x768") - - self.running = False - self.elapsed_time = 0 - self.start_time = None - self.times_list = [] - self.times_file = data_path("3x3_times.json") - - self.phase = "idle" - self.inspection_start = None - self.inspection_time = 15000 - self.grace_time = 3000 - self.current_scramble = generate_scramble() - - self.times_window = None - - self.load_times() - - main_frame = tk.Frame(root, bg="#1a1a1a") - main_frame.pack(expand=True, fill=tk.BOTH, padx=20, pady=20) - - title = tk.Label(main_frame, text="3x3 Speed Cube Timer", - font=("Arial", 24, "bold"), bg="#1a1a1a", fg="#00ff00") - title.pack(pady=10) - - self.scramble_frame = tk.Frame(main_frame, bg="#1a1a1a") - self.scramble_frame.pack(pady=(0, 5)) - - self.new_scramble_btn = tk.Button(self.scramble_frame, text="New Scramble", - command=self.new_scramble, - font=("Arial", 10), bg="#333333", fg="#00cfff", - padx=6, pady=3, cursor="hand2", relief="flat") - self.visualize_btn = tk.Button(self.scramble_frame, text="Visualize Scramble", - command=lambda: open_visualizer(self.root, self.current_scramble), - font=("Arial", 10), bg="#333333", fg="#ff9900", - padx=6, pady=3, cursor="hand2", relief="flat") - - self.scramble_label = tk.Label(self.scramble_frame, text="", - font=("Courier", 13, "bold"), bg="#1a1a1a", fg="#00cfff", - wraplength=1200, justify=tk.CENTER) - - self.timer_label = tk.Label(main_frame, text="00:00.00", - font=("Arial", 100, "bold"), - bg="#1a1a1a", fg="#00ff00") - self.timer_label.pack(pady=20) - - self.status_label = tk.Label(main_frame, text="Press SPACE to see scramble", - font=("Arial", 16), bg="#1a1a1a", fg="#ffff00") - self.status_label.pack(pady=10) - - button_frame = tk.Frame(main_frame, bg="#1a1a1a") - button_frame.pack(pady=20) - - view_btn = tk.Button(button_frame, text="View Times", command=self.view_times, - font=("Arial", 12), bg="#0066ff", fg="white", - padx=10, pady=10, cursor="hand2") - view_btn.grid(row=0, column=0, padx=10) - - clear_btn = tk.Button(button_frame, text="Clear Times", command=self.clear_times, - font=("Arial", 12), bg="#ff0000", fg="white", - padx=10, pady=10, cursor="hand2") - clear_btn.grid(row=0, column=1, padx=10) - - info = tk.Label(main_frame, - text="Press SPACE to Start\nPress SPACE to Stop\nPress ESC to Cancel\nPress ESC to Exit", - font=("Arial", 12), bg="#1a1a1a", fg="#cccccc", - justify=tk.CENTER) - info.pack(pady=20) - - self.root.bind('', self.key_press) - self.root.bind('', self.exit_or_cancel) - - self.update_timer() - - def format_time(self, milliseconds): - milliseconds = int(milliseconds) - seconds = milliseconds // 1000 - ms = (milliseconds % 1000) // 10 - minutes = seconds // 60 - seconds = seconds % 60 - return f"{minutes:02d}:{seconds:02d}.{ms:02d}" - - def key_press(self, event): - if self.phase == "idle": - self.phase = "scramble_reveal" - self.scramble_label.config(text=self.current_scramble) - self.scramble_label.pack(pady=(4, 0)) - self.new_scramble_btn.pack(side="left", padx=6, pady=4) - self.visualize_btn.pack(side="left", padx=6, pady=4) - self.status_label.config(text="Press SPACE to START inspection time", fg="#ffff00") - - elif self.phase == "scramble_reveal": - self.phase = "inspection" - self.scramble_label.pack_forget() - self.new_scramble_btn.pack_forget() - self.visualize_btn.pack_forget() - self.inspection_start = time.perf_counter() - self.status_label.config(text="INSPECTION: 15 seconds", fg="#00ff00") - - elif self.phase == "inspection": - pass - - elif self.phase == "grace": - pass - - elif self.phase == "ready": - self.phase = "solving" - self.running = True - self.start_time = time.perf_counter() - self.elapsed_time = 0 - self.status_label.config(text="SOLVING... Press SPACE to STOP", fg="#00ff00") - - elif self.phase == "solving": - self.running = False - self.phase = "idle" - solve_time = self.elapsed_time - if solve_time > 0: - self.times_list.append({ - 'time': solve_time, - 'date': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - 'scramble': self.current_scramble - }) - self.save_times() - self.current_scramble = generate_scramble() - self.scramble_label.pack_forget() - self.new_scramble_btn.pack_forget() - self.visualize_btn.pack_forget() - self.status_label.config(text="Solve Stopped! Press SPACE to see scramble", fg="#ffff00") - if solve_time > 0: - self.ask_show_times(solve_time) - - def exit_or_cancel(self, event): - if self.phase == "inspection" or self.phase == "grace": - self.phase = "idle" - self.inspection_start = None - self.scramble_label.pack_forget() - self.new_scramble_btn.pack_forget() - self.visualize_btn.pack_forget() - self.status_label.config(text="Cancelled. Press SPACE to see scramble", fg="#ffff00") - elif self.phase == "solving": - self.running = False - self.phase = "idle" - self.scramble_label.pack_forget() - self.new_scramble_btn.pack_forget() - self.visualize_btn.pack_forget() - self.status_label.config(text="Cancelled. Press SPACE to see scramble", fg="#ffff00") - else: - self.root.quit() - - def new_scramble(self): - self.current_scramble = generate_scramble() - self.scramble_label.config(text=self.current_scramble) - - def ask_show_times(self, solve_time): - response = messagebox.askyesno("Solve Complete!", - f"Solve Time: {self.format_time(solve_time)}\n\nView your times?") - if response: - self.view_times() - - def update_timer(self): - if self.running: - self.elapsed_time = int((time.perf_counter() - self.start_time) * 1000) - self.timer_label.config(text=self.format_time(self.elapsed_time)) - - if self.phase == "inspection": - elapsed = int((time.perf_counter() - self.inspection_start) * 1000) - remaining = self.inspection_time - elapsed - if remaining > 0: - self.timer_label.config(text=self.format_time(remaining), fg="#ffaa00") - else: - self.phase = "grace" - self.inspection_start = time.perf_counter() - self.status_label.config(text="READY: 3 seconds", fg="#0099ff") - - elif self.phase == "grace": - elapsed = int((time.perf_counter() - self.inspection_start) * 1000) - remaining = self.grace_time - elapsed - if remaining > 0: - self.timer_label.config(text=self.format_time(remaining), fg="#0099ff") - else: - self.phase = "ready" - self.timer_label.config(text="00:00.00", fg="#00ff00") - self.status_label.config(text="PRESS SPACE TO START SOLVING", fg="#ff0000") - - elif self.phase == "ready": - self.timer_label.config(text="00:00.00", fg="#00ff00") - - elif self.phase == "idle" and not self.running: - self.timer_label.config(text="00:00.00", fg="#00ff00") - - self.root.after(10, self.update_timer) - - def view_times(self): - if not self.times_list: - messagebox.showinfo("Times", "No times recorded yet!") - return - - if self.times_window is not None and self.times_window.winfo_exists(): - self.times_window.lift() - self.times_window.focus() - return - - self.times_window = tk.Toplevel(self.root) - self.times_window.title("3x3 Solve Times") - self.times_window.configure(bg="#1a1a1a") - try: - self.times_window.state("zoomed") - except: - self.times_window.geometry("800x600") - - def on_close(): - self.times_window.destroy() - self.times_window = None - - self.times_window.protocol("WM_DELETE_WINDOW", on_close) - - title = tk.Label(self.times_window, text="Your Solve Times", - font=("Arial", 18, "bold"), bg="#1a1a1a", fg="#00ff00") - title.pack(pady=10) - - times_in_ms = [entry['time'] for entry in self.times_list] - avg_time = sum(times_in_ms) / len(times_in_ms) - best_time = min(times_in_ms) - worst_time = max(times_in_ms) - - main_frame = tk.Frame(self.times_window, bg="#1a1a1a") - main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - canvas = tk.Canvas(main_frame, bg="#0a0a0a", highlightthickness=0) - scrollbar = tk.Scrollbar(main_frame, orient="vertical", command=canvas.yview) - scrollable_frame = tk.Frame(canvas, bg="#0a0a0a") - - scrollable_frame.bind( - "", - lambda e: canvas.configure(scrollregion=canvas.bbox("all")) - ) - - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - canvas.pack(side="left", fill=tk.BOTH, expand=True) - scrollbar.pack(side="right", fill="y") - - stats_frame = tk.Frame(scrollable_frame, bg="#0a0a0a") - stats_frame.pack(fill=tk.X, padx=10, pady=10) - - tk.Label(stats_frame, text="═"*50, font=("Arial", 10), bg="#0a0a0a", fg="#666666").pack() - tk.Label(stats_frame, text=f"Total Solves: {len(self.times_list)}", - font=("Arial", 12, "bold"), bg="#0a0a0a", fg="#ffffff").pack(anchor="w") - tk.Label(stats_frame, text=f"Best: {self.format_time(best_time)}", - font=("Arial", 12, "bold"), bg="#0a0a0a", fg="#00ff00").pack(anchor="w") - tk.Label(stats_frame, text=f"Worst: {self.format_time(worst_time)}", - font=("Arial", 12, "bold"), bg="#0a0a0a", fg="#ff0000").pack(anchor="w") - tk.Label(stats_frame, text=f"Average: {self.format_time(avg_time)}", - font=("Arial", 12, "bold"), bg="#0a0a0a", fg="#ffff00").pack(anchor="w") - tk.Label(stats_frame, text="═"*50, font=("Arial", 10), bg="#0a0a0a", fg="#666666").pack() - - tk.Label(scrollable_frame, text="All Solves:", font=("Arial", 12, "bold"), - bg="#0a0a0a", fg="#ffffff").pack(anchor="w", padx=10, pady=(10, 5)) - - for i, entry in enumerate(self.times_list): - solve_time = entry.get('time', 0) - time_str = self.format_time(solve_time) - date_str = entry.get('date', 'N/A') - - if solve_time == best_time: - color = "#00ff00" - elif solve_time == worst_time: - color = "#ff0000" - elif solve_time >= 40000: - color = "#8B4513" - else: - color = "#ffff00" - - solve_frame = tk.Frame(scrollable_frame, bg="#1a1a1a") - solve_frame.pack(fill=tk.X, padx=10, pady=3) - - solve_label = tk.Label(solve_frame, - text=f"{i+1}. {time_str} - {date_str}", - font=("Arial", 11), bg="#1a1a1a", fg=color, anchor="w") - solve_label.pack(side="left", fill=tk.X, expand=True) - - scr = entry.get('scramble', '') - if scr: - scr_label = tk.Label(solve_frame, - text=scr, - font=("Courier", 9), bg="#1a1a1a", fg="#888888", anchor="w") - scr_label.pack(side="left", fill=tk.X, expand=True, padx=(8, 0)) - - delete_btn = tk.Button(solve_frame, text="✕", - command=lambda idx=i: self.delete_solve(idx), - font=("Arial", 10), bg="#ff4444", fg="white", - padx=5, pady=0, cursor="hand2", bd=0) - delete_btn.pack(side="right", padx=5) - - def delete_solve(self, index): - if index < 0 or index >= len(self.times_list): - return - response = messagebox.askyesno("Delete Solve", - f"Delete solve: {self.format_time(self.times_list[index]['time'])}?") - if response: - del self.times_list[index] - self.save_times() - if self.times_window and self.times_window.winfo_exists(): - self.times_window.destroy() - self.times_window = None - self.view_times() - - def clear_times(self): - response = messagebox.askyesno("Clear Times", - "Are you sure you want to clear all times?") - if response: - self.times_list = [] - self.save_times() - messagebox.showinfo("Success", "All times cleared!") - self.elapsed_time = 0 - self.running = False - self.phase = "idle" - self.scramble_label.pack_forget() - self.new_scramble_btn.pack_forget() - self.visualize_btn.pack_forget() - self.status_label.config(text="Press SPACE to see scramble", fg="#ffff00") - if self.times_window and self.times_window.winfo_exists(): - self.times_window.destroy() - self.times_window = None - - def save_times(self): - try: - temp_fd, temp_path = tempfile.mkstemp(suffix='.json', text=True) - try: - with os.fdopen(temp_fd, 'w') as f: - json.dump(self.times_list, f, indent=2) - shutil.move(temp_path, self.times_file) - except: - try: - os.unlink(temp_path) - except: - pass - raise - except Exception as e: - messagebox.showerror("Save Error", f"Failed to save times: {e}") - - def load_times(self): - if os.path.exists(self.times_file): - try: - with open(self.times_file, 'r') as f: - data = json.load(f) - if isinstance(data, list): - self.times_list = [entry for entry in data if isinstance(entry, dict) and 'time' in entry] - else: - self.times_list = [] - except json.JSONDecodeError: - messagebox.showerror("Error", "Times file is corrupted. Starting fresh.") - self.times_list = [] - except PermissionError: - messagebox.showerror("Error", "Permission denied reading times file.") - self.times_list = [] - except Exception as e: - messagebox.showerror("Error", f"Failed to load times: {e}") - self.times_list = [] - else: - self.times_list = [] - - def exit_app(self, event=None): - self.root.quit() - -if __name__ == "__main__": - root = tk.Tk() - app = SpeedCubeTimer(root) - root.mainloop() diff --git a/README.md b/README.md index de2482b..fea89d8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ A modern desktop timer for 3×3 Rubik's Cube solves, built with Python & CustomT [![Version](https://img.shields.io/badge/Version-2.3-00ff00?style=for-the-badge)](https://github.com/L3gitFoxy/3x3_timer) [![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://python.org) [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-0078D6?style=for-the-badge&logo=windows&logoColor=white)](https://microsoft.com/windows) +[![CI](https://img.shields.io/github/actions/workflow/status/L3gitFoxy/3x3_timer/ci.yml?branch=main&style=for-the-badge&logo=github&label=CI)](https://github.com/L3gitFoxy/3x3_timer/actions) +[![Tests](https://img.shields.io/badge/Tests-65%20passing-00cc00?style=for-the-badge&logo=pytest)](https://github.com/L3gitFoxy/3x3_timer) +[![Ruff](https://img.shields.io/badge/Linted%20with-Ruff-ffcc00?style=for-the-badge&logo=ruff)](https://github.com/astral-sh/ruff) +[![Mypy](https://img.shields.io/badge/Type%20checked-Mypy-2a6db2?style=for-the-badge&logo=python)](https://mypy-lang.org/) [![Stars](https://img.shields.io/github/stars/L3gitFoxy/3x3_timer?style=for-the-badge)](https://github.com/L3gitFoxy/3x3_timer/stargazers) [![Issues](https://img.shields.io/github/issues/L3gitFoxy/3x3_timer?style=for-the-badge)](https://github.com/L3gitFoxy/3x3_timer/issues) @@ -14,24 +18,55 @@ Simple. Fast. Offline. +--- + ## Table of Contents +- [Quick Start](#quick-start) - [Overview](#overview) - [Features](#features) +- [Changelog](#changelog) - [Installation](#installation) +- [Running](#running) - [Project Structure](#project-structure) - [Data Storage](#data-storage) +- [Development](#development) - [Requirements](#requirements) - [Contributing](#contributing) - [Security](#security) - [License](#license) +--- + +## Quick Start + +**30 seconds to your first solve:** + +```bash +# Option 1: One-command install (requires Python 3.10+) +pip install git+https://github.com/L3gitFoxy/3x3_timer.git && 3x3_timer + +# Option 2: Run directly (no install) +git clone https://github.com/L3gitFoxy/3x3_timer.git +cd 3x3_timer +python run.py + +# Option 3: Double-click (Windows) +run.bat +``` + +On first launch, a welcome dialog explains the timer flow. Press **ANY KEY** to start inspection, then follow the on-screen prompts. + +--- + ## Overview **3x3 Timer** is a modern desktop timer for recording Rubik's Cube solves with a sleek CustomTkinter dark-theme UI. Solve history is stored locally, making the application completely offline with no external services or accounts required. +--- + ## Features - **Modern UI** — CustomTkinter dark theme with rounded widgets, hover effects, and dynamic font scaling @@ -45,11 +80,33 @@ Solve history is stored locally, making the application completely offline with - **CSV export** — export all solve times to CSV for external analysis - **Responsive layout** — window resizes proportionally, timer font scales - **Cross-platform** (Windows, macOS, Linux) +- **First-run welcome dialog** — explains the timer flow for new users +- **One-click launchers** — `run.bat` (Windows) and `run.sh` (macOS/Linux) auto-create virtual environments - No internet connection required - No external database - Lightweight multi-file modular application -## Installation +--- + +## Changelog + +### v2.3 (Current) +- **CSV export** — Export all solves to CSV with native save dialog +- Statistics now include Ao5, Ao12, Ao100 sliding averages + +### v2.2 +- **WCA-compliant scramble generation** — parallel-face sequences (U/D, R/L, F/B) are now prevented per WCA regulation 4b3 +- **Sliding averages** — Ao5, Ao12, Ao100 displayed in the stats panel + +### v2.1 +- **CustomTkinter migration** — modern rounded dark-theme widgets +- **Inline scramble** — always visible, no reveal/hide logic +- **Times graph** — canvas-based line chart in the View Times window +- **Responsive layout** — proportional scaling on resize + +--- + +## Installation & Running ### One-command install (requires Python 3.10+) @@ -72,6 +129,24 @@ cd 3x3_timer python run.py ``` +### One-click launchers (auto-create virtual environment) + +**Windows:** Double-click `run.bat` +**macOS/Linux:** Run `./run.sh` in the terminal + +These scripts automatically create a Python virtual environment, install dependencies, and launch the app — no manual setup required. + +### Install with pipx (recommended for isolation) + +```bash +pipx install git+https://github.com/L3gitFoxy/3x3_timer.git +3x3_timer +``` + +[pipx](https://pypa.github.io/pipx/) installs the app in an isolated environment so it doesn't interfere with other Python packages. + +--- + ## Project Structure ```text @@ -84,35 +159,101 @@ python run.py │ ├── storage.py # JSON persistence & validation │ ├── ui.py # CustomTkinter graphical interface │ └── visualizer.py # Isometric 3D cube visualizer +├── tests/ +│ ├── __init__.py # Test suite marker +│ ├── test_timer.py # Timer state machine tests (20 tests) +│ ├── test_scramble.py # Scramble generation tests (22 tests) +│ └── test_storage.py # JSON persistence tests (23 tests) ├── data/ │ └── times.json # Solve history (gitignored) +├── .github/workflows/ +│ └── ci.yml # CI pipeline (lint, type-check, test) +├── .pre-commit-config.yaml # Git hooks (ruff, mypy, formatting) ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md -└── 3x3.py # Full length code without any divisions +├── pyproject.toml # Project config & tool settings +├── run.bat # Windows one-click launcher +├── run.sh # Unix one-click launcher +└── run.py # Entry point ``` +--- + ## Data Storage Solve times are stored locally in `data/times.json`. No cloud services, analytics, or user accounts are used. +--- + +## Development + +### Setup + +```bash +# Clone the repo +git clone https://github.com/L3gitFoxy/3x3_timer.git +cd 3x3_timer + +# Install with dev dependencies +pip install -e ".[dev]" + +# Install pre-commit hooks +pre-commit install +``` + +### Run tests + +```bash +pytest --cov=src +``` + +### Lint & format + +```bash +ruff check src/ tests/ +ruff format src/ tests/ +``` + +### Type check + +```bash +mypy src/ tests/ +``` + +### Pre-commit hooks + +The project uses [pre-commit](https://pre-commit.com/) to automatically run ruff (lint + format) and mypy before every commit. After installing dev dependencies, run: + +```bash +pre-commit install +``` + +--- + ## Requirements - Python 3.10 or newer - CustomTkinter >= 5.2.2 (installed automatically via pip) +--- + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). +--- + ## Security See [SECURITY.md](SECURITY.md). +--- + ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2e7e539..59702d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +13,48 @@ dependencies = [ "customtkinter>=5.2.2", ] +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "ruff>=0.5.0", + "mypy>=1.10.0", + "pre-commit>=3.7.0", +] + [tool.setuptools.packages.find] where = ["src"] [project.scripts] 3x3_timer = "ui:main" + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "ARG", "PTH", "PL"] +ignore = [ + "N806", # Allow uppercase variable names (e.g. cube face notation U, D, F, B, L, R) + "PLR2004", # Allow magic values in comparisons (tests and UI constants are fine) + "PLR0915", # Allow long functions (UI methods are inherently long) + "ARG002", # Allow unused method arguments (e.g. event handlers) + "ARG005", # Allow unused lambda arguments +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.mypy] +python_version = "3.10" +strict = true +ignore_missing_imports = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unused_configs = true + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +pythonpath = ["src"] \ No newline at end of file diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..925ea18 --- /dev/null +++ b/run.bat @@ -0,0 +1,36 @@ +@echo off +REM 3x3 Speed Cube Timer - Windows Launcher +REM Auto-creates a virtual environment and launches the app. + +setlocal enabledelayedexpansion + +cd /d "%~dp0" + +set VENV_DIR=%~dp0.venv + +if not exist "%VENV_DIR%\Scripts\python.exe" ( + echo [3x3 Timer] Creating virtual environment... + python -m venv "%VENV_DIR%" + if !errorlevel! neq 0 ( + echo [ERROR] Failed to create virtual environment. Make sure Python 3.10+ is installed. + pause + exit /b 1 + ) + echo [3x3 Timer] Installing dependencies... + "%VENV_DIR%\Scripts\python.exe" -m pip install -e . >nul 2>&1 + if !errorlevel! neq 0 ( + echo [ERROR] Failed to install dependencies. + pause + exit /b 1 + ) +) + +echo [3x3 Timer] Starting application... +"%VENV_DIR%\Scripts\python.exe" run.py +if !errorlevel! neq 0 ( + echo [ERROR] Application exited with an error. + pause + exit /b 1 +) + +pause \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..727828d --- /dev/null +++ b/run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# 3x3 Speed Cube Timer - Unix Launcher (macOS/Linux) +# Auto-creates a virtual environment and launches the app. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +VENV_DIR="$SCRIPT_DIR/.venv" + +if [ ! -f "$VENV_DIR/bin/python" ]; then + echo "[3x3 Timer] Creating virtual environment..." + python3 -m venv "$VENV_DIR" + echo "[3x3 Timer] Installing dependencies..." + "$VENV_DIR/bin/python" -m pip install -e . >/dev/null 2>&1 +fi + +echo "[3x3 Timer] Starting application..." +exec "$VENV_DIR/bin/python" run.py \ No newline at end of file diff --git a/src/scramble.py b/src/scramble.py index 47afb30..4caad0f 100644 --- a/src/scramble.py +++ b/src/scramble.py @@ -8,15 +8,14 @@ from __future__ import annotations import random -from typing import Dict, List # ── Constants ──────────────────────────────────────────────────────── -MOVES: List[str] = ["U", "D", "L", "R", "F", "B"] -MODIFIERS: List[str] = ["", "'", "2"] +MOVES: list[str] = ["U", "D", "L", "R", "F", "B"] +MODIFIERS: list[str] = ["", "'", "2"] # Standard Western colour scheme -FACE_COLORS: Dict[str, str] = { +FACE_COLORS: dict[str, str] = { "U": "#ffffff", # white "D": "#ffff00", # yellow "F": "#00aa00", # green @@ -25,7 +24,7 @@ "R": "#ff0000", # red } -CubeState = Dict[str, List[str]] +CubeState = dict[str, list[str]] """Maps face letter → list of 9 sticker colours (reading order).""" @@ -102,12 +101,12 @@ def apply_scramble(cube: CubeState, scramble: str) -> None: apply_move(cube, m) -def compute_states(scramble: str) -> List[CubeState]: +def compute_states(scramble: str) -> list[CubeState]: """Return a list of cube states from solved → after each move. Useful for stepping through a scramble visually. """ - states: List[CubeState] = [make_solved_cube()] + states: list[CubeState] = [make_solved_cube()] for m in scramble.split(): nxt = {f: list(states[-1][f]) for f in "UDFBLR"} apply_move(nxt, m) @@ -117,7 +116,7 @@ def compute_states(scramble: str) -> List[CubeState]: # ── Opposing face pairs (WCA regulation 4b3) ───────────────────────── -OPPOSITES: Dict[str, str] = {"U": "D", "D": "U", "R": "L", "L": "R", "F": "B", "B": "F"} +OPPOSITES: dict[str, str] = {"U": "D", "D": "U", "R": "L", "L": "R", "F": "B", "B": "F"} # ── Scramble generation ────────────────────────────────────────────── @@ -128,7 +127,7 @@ def generate_scramble(length: int = 20) -> str: - No move on the *opposite* face after a move on its parallel face (WCA regulation 4b3 — e.g. R is never followed by L, U never by D). """ - scramble: List[str] = [] + scramble: list[str] = [] last: str | None = None for _ in range(length): available = [ diff --git a/src/storage.py b/src/storage.py index 9a3e459..1e52085 100644 --- a/src/storage.py +++ b/src/storage.py @@ -3,10 +3,8 @@ from __future__ import annotations import json -import os from pathlib import Path -from typing import Any, List, Dict - +from typing import Any DATA_DIR = Path(__file__).resolve().parent.parent / "data" DEFAULT_FILE = DATA_DIR / "times.json" @@ -18,7 +16,7 @@ def get_data_path(filename: str = "times.json") -> Path: return DATA_DIR / filename -def load_times(path: Path = DEFAULT_FILE) -> List[Dict[str, Any]]: +def load_times(path: Path = DEFAULT_FILE) -> list[dict[str, Any]]: """Load solve times from *path*. Returns an empty list if the file is missing, empty, or contains corrupt data. @@ -27,7 +25,7 @@ def load_times(path: Path = DEFAULT_FILE) -> List[Dict[str, Any]]: return [] try: - with open(path, "r", encoding="utf-8") as f: + with path.open(encoding="utf-8") as f: data: Any = json.load(f) # Validate — we expect a list of objects with 'time' and 'date' if isinstance(data, list) and all( @@ -39,8 +37,8 @@ def load_times(path: Path = DEFAULT_FILE) -> List[Dict[str, Any]]: return [] -def save_times(times: List[Dict[str, Any]], path: Path = DEFAULT_FILE) -> None: +def save_times(times: list[dict[str, Any]], path: Path = DEFAULT_FILE) -> None: """Persist *times* to *path* as pretty-printed JSON.""" path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - json.dump(times, f, indent=2) \ No newline at end of file + with path.open("w", encoding="utf-8") as f: + json.dump(times, f, indent=2) diff --git a/src/timer.py b/src/timer.py index 9c42a27..5fa7b07 100644 --- a/src/timer.py +++ b/src/timer.py @@ -4,7 +4,6 @@ import time from enum import Enum, auto -from typing import Optional class TimerPhase(Enum): @@ -29,7 +28,7 @@ def __init__(self) -> None: self.phase: TimerPhase = TimerPhase.IDLE self.running: bool = False self.elapsed_ms: int = 0 - self._start_time: Optional[float] = None + self._start_time: float | None = None self._phase_start: float = 0.0 # ------------------------------------------------------------------ @@ -135,4 +134,4 @@ def format_time(milliseconds: int) -> str: minutes = seconds // 60 seconds %= 60 centiseconds = (ms % 1000) // 10 - return f"{minutes:02d}:{seconds:02d}.{centiseconds:02d}" \ No newline at end of file + return f"{minutes:02d}:{seconds:02d}.{centiseconds:02d}" diff --git a/src/ui.py b/src/ui.py index e72eb92..b6073a5 100644 --- a/src/ui.py +++ b/src/ui.py @@ -4,14 +4,16 @@ import csv import tkinter as tk # for Canvas (no CTkCanvas in customtkinter 5.x) +import tkinter.messagebox as mb from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional +from tkinter import filedialog +from typing import Any import customtkinter as ctk from scramble import generate_scramble -from storage import load_times, save_times +from storage import get_data_path, load_times, save_times from timer import SpeedCubeTimer, TimerPhase, format_time from visualizer import open_visualizer @@ -30,13 +32,14 @@ def __init__(self, root: ctk.CTk) -> None: self.root.geometry("700x580") self.timer = SpeedCubeTimer() - self.times: List[Dict[str, Any]] = load_times() + self.times: list[dict[str, Any]] = load_times() self.current_scramble: str = generate_scramble() self._times_window: ctk.CTkToplevel | None = None self._build_ui() self._bind_keys() self._update() + self._show_welcome_if_first_run() # ── UI construction ───────────────────────────────────────────── @@ -159,10 +162,7 @@ def _on_key(self, event: tk.Event) -> None: if phase == TimerPhase.IDLE: self.timer.start_inspection() - elif phase == TimerPhase.INSPECTION: - self.timer.cancel() - - elif phase == TimerPhase.GRACE: + elif phase in (TimerPhase.INSPECTION, TimerPhase.GRACE): self.timer.cancel() elif phase == TimerPhase.READY: @@ -186,7 +186,9 @@ def _on_escape(self, _event: tk.Event) -> None: self.timer.cancel() elif phase == TimerPhase.SOLVING: self.timer.reset() - self._status_label.configure(text="Press ANY KEY to start inspection", text_color="#ffff00") + self._status_label.configure( + text="Press ANY KEY to start inspection", text_color="#ffff00" + ) else: self.root.quit() @@ -218,7 +220,9 @@ def _refresh_display(self) -> None: self._status_label.configure(text="READY TO SOLVE: 3 seconds", text_color="#0099ff") self._timer_label.configure(text=format_time(ms), text_color="#0099ff") elif phase == TimerPhase.READY: - self._status_label.configure(text="PRESS ANY KEY TO START SOLVING", text_color="#ff0000") + self._status_label.configure( + text="PRESS ANY KEY TO START SOLVING", text_color="#ff0000" + ) self._timer_label.configure(text="00:00.00", text_color="#00ff00") elif phase == TimerPhase.SOLVING: self._status_label.configure( @@ -227,20 +231,22 @@ def _refresh_display(self) -> None: ) self._timer_label.configure(text=format_time(ms), text_color="#00ff00") else: # IDLE - self._status_label.configure(text="Press ANY KEY to start inspection", text_color="#ffff00") + self._status_label.configure( + text="Press ANY KEY to start inspection", text_color="#ffff00" + ) self._timer_label.configure(text="00:00.00", text_color="#00ff00") # ── Statistics ────────────────────────────────────────────────── @staticmethod - def _sliding_avg(times_ms: List[int], window: int) -> Optional[int]: + def _sliding_avg(times_ms: list[int], window: int) -> int | None: """Return the most recent sliding-window average, or None if not enough solves.""" if len(times_ms) < window: return None recent = times_ms[-window:] return int(sum(recent) / window) - def _stats(self) -> Dict[str, Any]: + def _stats(self) -> dict[str, Any]: times_ms = [e["time"] for e in self.times] if not times_ms: return {"count": 0} @@ -256,7 +262,7 @@ def _stats(self) -> Dict[str, Any]: # ── Times graph (canvas-based) ────────────────────────────────── - def _draw_times_graph(self, canvas: tk.Canvas, times_ms: List[int]) -> None: + def _draw_times_graph(self, canvas: tk.Canvas, times_ms: list[int]) -> None: """Draw a line chart of solve times on the given canvas.""" canvas.delete("all") n = len(times_ms) @@ -314,7 +320,7 @@ def _draw_times_graph(self, canvas: tk.Canvas, times_ms: List[int]) -> None: ) # ── Data line ── - points: List[float] = [] + points: list[float] = [] for i, t in enumerate(times_ms): x = margin_l + (plot_w * i / (n - 1)) if n > 1 else margin_l + plot_w // 2 y = margin_t + plot_h - (plot_h * (t - mn) / rng) @@ -349,9 +355,6 @@ def _draw_times_graph(self, canvas: tk.Canvas, times_ms: List[int]) -> None: def _view_times(self) -> None: if not self.times: - ctk.CTkMessagebox = None - # Use CTkInputDialog-like approach; fallback to messagebox - import tkinter.messagebox as mb mb.showinfo("Times", "No times recorded yet!", parent=self.root) return @@ -366,7 +369,8 @@ def _view_times(self) -> None: self._times_window.geometry("900x650") def on_close() -> None: - self._times_window.destroy() + if self._times_window is not None: + self._times_window.destroy() self._times_window = None self._times_window.protocol("WM_DELETE_WINDOW", on_close) @@ -406,7 +410,10 @@ def on_close() -> None: lambda: self._draw_times_graph(graph_canvas, times_ms), ) # Redraw on resize - graph_canvas.bind("", lambda e: self._draw_times_graph(graph_canvas, times_ms)) + graph_canvas.bind( + "", + lambda _e: self._draw_times_graph(graph_canvas, times_ms), + ) # ── Scrollable content ── scroll_container = ctk.CTkScrollableFrame(self._times_window, fg_color="transparent") @@ -496,11 +503,10 @@ def on_close() -> None: def _export_csv(self) -> None: """Export all solve times to a CSV file.""" if not self.times: - import tkinter.messagebox as mb - mb.showinfo("Export", "No times to export!", parent=self._times_window) + parent_win: Any = self._times_window + mb.showinfo("Export", "No times to export!", parent=parent_win) return - from tkinter import filedialog filename = filedialog.asksaveasfilename( parent=self._times_window, title="Export Times as CSV", @@ -511,7 +517,7 @@ def _export_csv(self) -> None: return try: - with open(filename, "w", newline="", encoding="utf-8") as f: + with Path(filename).open("w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["#", "Time (ms)", "Time (formatted)", "Date", "Scramble"]) for idx, entry in enumerate(self.times): @@ -522,26 +528,26 @@ def _export_csv(self) -> None: entry["date"], entry.get("scramble", ""), ]) - import tkinter.messagebox as mb mb.showinfo( "Export Successful", f"Exported {len(self.times)} solves to:\n{filename}", - parent=self._times_window, + parent=self._times_window, # type: ignore[arg-type] ) except (OSError, PermissionError) as e: - import tkinter.messagebox as mb - mb.showerror("Export Failed", str(e), parent=self._times_window) + mb.showerror("Export Failed", str(e), parent=self._times_window) # type: ignore[arg-type] # ── Delete & Clear ────────────────────────────────────────────── def _delete_solve(self, index: int) -> None: if index < 0 or index >= len(self.times): return - import tkinter.messagebox as mb + parent_win: ctk.CTkToplevel | ctk.CTk = ( + self._times_window if self._times_window else self.root + ) if mb.askyesno( "Delete Solve", f"Delete solve: {format_time(self.times[index]['time'])}?", - parent=self._times_window if self._times_window else self.root, + parent=parent_win, ): del self.times[index] save_times(self.times) @@ -551,12 +557,13 @@ def _delete_solve(self, index: int) -> None: self._view_times() def _clear_times(self) -> None: - import tkinter.messagebox as mb if mb.askyesno("Clear Times", "Are you sure you want to clear all times?"): self.times.clear() save_times(self.times) self.timer.reset() - self._status_label.configure(text="Press ANY KEY to start inspection", text_color="#ffff00") + self._status_label.configure( + text="Press ANY KEY to start inspection", text_color="#ffff00" + ) if self._times_window and self._times_window.winfo_exists(): self._times_window.destroy() self._times_window = None @@ -564,8 +571,31 @@ def _clear_times(self) -> None: # ── Dialog after solve ────────────────────────────────────────── + def _show_welcome_if_first_run(self) -> None: + """Show a welcome dialog on first launch to explain the timer flow.""" + welcome_flag = get_data_path(".welcome_shown") + if welcome_flag.exists(): + return + + mb.showinfo( + "Welcome to 3x3 Speed Cube Timer!", + "Here's how to get started:\n\n" + "1. Press ANY KEY to start the 15-second inspection (look at the scramble)\n" + "2. After inspection, you have 3 seconds of grace time\n" + "3. Press ANY KEY again to start solving\n" + "4. Press ANY KEY to stop the timer\n" + "5. View your times with the 'View Times' button\n\n" + "Keyboard shortcuts:\n" + " SPACE / ENTER — Start/Stop\n" + " ESC — Cancel / Quit\n\n" + "Happy cubing! 🧩", + parent=self.root, + ) + # Mark welcome as shown + welcome_flag.parent.mkdir(parents=True, exist_ok=True) + welcome_flag.touch() + def _ask_show_times(self, elapsed: int) -> None: - import tkinter.messagebox as mb if mb.askyesno( "Solve Complete!", f"Solve Time: {format_time(elapsed)}\n\nView your times?", @@ -578,5 +608,5 @@ def main() -> None: root = ctk.CTk() root.minsize(600, 500) root.geometry("700x580") - app = Application(root) - root.mainloop() \ No newline at end of file + Application(root) + root.mainloop() diff --git a/src/visualizer.py b/src/visualizer.py index 9234e31..5e9c5b9 100644 --- a/src/visualizer.py +++ b/src/visualizer.py @@ -7,7 +7,6 @@ from __future__ import annotations import tkinter as tk -from typing import List from scramble import FACE_COLORS, CubeState, compute_states @@ -47,9 +46,9 @@ def open_visualizer(parent: tk.Tk | tk.Toplevel, scramble_str: str) -> None: canvas.pack() # ── Pre-compute all cube states ───────────────────────────────── - cube_states: List[CubeState] = compute_states(scramble_str) + cube_states: list[CubeState] = compute_states(scramble_str) - step_idx: List[int] = [0] + step_idx: list[int] = [0] # ── Drawing helpers ───────────────────────────────────────────── def shade(color: str, factor: float) -> str: @@ -133,4 +132,4 @@ def on_key(event: tk.Event) -> None: refresh() win.bind("", on_key) - refresh() \ No newline at end of file + refresh() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e1cd07e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for 3x3 Speed Cube Timer.""" diff --git a/tests/test_scramble.py b/tests/test_scramble.py new file mode 100644 index 0000000..4096493 --- /dev/null +++ b/tests/test_scramble.py @@ -0,0 +1,183 @@ +"""Tests for the scramble generator and cube simulation.""" + +from __future__ import annotations + +import re + +import pytest + +from scramble import ( + FACE_COLORS, + MOVES, + OPPOSITES, + CubeState, + apply_move, + apply_scramble, + compute_states, + generate_scramble, + make_solved_cube, +) + + +@pytest.fixture +def solved_cube() -> CubeState: + """Provide a fresh solved cube for each test.""" + return make_solved_cube() + + +class TestMakeSolvedCube: + """A solved cube should have uniform faces.""" + + def test_all_faces_present(self, solved_cube: CubeState) -> None: + assert set(solved_cube.keys()) == {"U", "D", "F", "B", "L", "R"} + + def test_each_face_has_9_stickers(self, solved_cube: CubeState) -> None: + for face in "UDFBLR": + assert len(solved_cube[face]) == 9 + + def test_each_face_is_solid(self, solved_cube: CubeState) -> None: + for face in "UDFBLR": + assert all(s == face for s in solved_cube[face]) + + +class TestApplyMove: + """Applying a single move should change the cube state.""" + + def test_r_move_changes_state(self, solved_cube: CubeState) -> None: + original = {f: list(solved_cube[f]) for f in "UDFBLR"} + apply_move(solved_cube, "R") + # R move should change U, F, D, B faces + assert solved_cube["U"] != original["U"] + assert solved_cube["F"] != original["F"] + assert solved_cube["D"] != original["D"] + assert solved_cube["B"] != original["B"] + + def test_r2_returns_to_original_after_4(self, solved_cube: CubeState) -> None: + original = {f: list(solved_cube[f]) for f in "UDFBLR"} + for _ in range(4): + apply_move(solved_cube, "R2") + assert solved_cube == original + + def test_r_prime_undoes_r(self, solved_cube: CubeState) -> None: + original = {f: list(solved_cube[f]) for f in "UDFBLR"} + apply_move(solved_cube, "R") + apply_move(solved_cube, "R'") + assert solved_cube == original + + def test_all_moves_are_valid(self, solved_cube: CubeState) -> None: + """Each base move should not crash and should change state.""" + for move in MOVES: + cube = make_solved_cube() + apply_move(cube, move) + assert cube != make_solved_cube() + + def test_all_modifiers_work(self, solved_cube: CubeState) -> None: + """Test each move with each modifier.""" + for move in MOVES: + for mod in ["", "'", "2"]: + cube = make_solved_cube() + apply_move(cube, move + mod) # Should not crash + + +class TestApplyScramble: + """Applying a full scramble string.""" + + def test_scramble_changes_state(self, solved_cube: CubeState) -> None: + scramble = "R U R' U'" + apply_scramble(solved_cube, scramble) + assert solved_cube != make_solved_cube() + + def test_scramble_is_reversible(self, solved_cube: CubeState) -> None: + scramble = "R U R' U'" + apply_scramble(solved_cube, scramble) + # Reverse the scramble + reverse_moves = scramble.split()[::-1] + reverse = " ".join( + m + "'" if "'" not in m and "2" not in m else + m.replace("'", "") if "'" in m else + m + for m in reverse_moves + ) + apply_scramble(solved_cube, reverse) + assert solved_cube == make_solved_cube() + + +class TestComputeStates: + """Step-by-step state computation.""" + + def test_returns_list(self) -> None: + states = compute_states("R U R'") + assert isinstance(states, list) + + def test_first_state_is_solved(self) -> None: + states = compute_states("R U R'") + assert states[0] == make_solved_cube() + + def test_length_is_moves_plus_one(self) -> None: + moves = "R U R' U' R' F R2 U' R' U'" + states = compute_states(moves) + assert len(states) == len(moves.split()) + 1 + + def test_each_state_differs(self) -> None: + states = compute_states("R U R' U'") + for i in range(1, len(states)): + assert states[i] != states[i - 1] + + +class TestGenerateScramble: + """Scramble generation should follow WCA rules.""" + + def test_default_length(self) -> None: + scramble = generate_scramble() + assert len(scramble.split()) == 20 + + def test_custom_length(self) -> None: + scramble = generate_scramble(length=10) + assert len(scramble.split()) == 10 + + def test_no_consecutive_same_face(self) -> None: + for _ in range(100): + scramble = generate_scramble() + moves = scramble.split() + for i in range(1, len(moves)): + assert moves[i][0] != moves[i - 1][0], ( + f"Consecutive same face: {moves[i-1]} {moves[i]}" + ) + + def test_no_parallel_face_sequences(self) -> None: + """WCA regulation 4b3: no move on opposite face after parallel face.""" + for _ in range(100): + scramble = generate_scramble() + moves = scramble.split() + for i in range(1, len(moves)): + prev_face = moves[i - 1][0] + curr_face = moves[i][0] + assert curr_face != OPPOSITES[prev_face], ( + f"Parallel face sequence: {moves[i-1]} {moves[i]} " + f"({prev_face} → {curr_face})" + ) + + def test_only_valid_moves(self) -> None: + for _ in range(50): + scramble = generate_scramble() + for move in scramble.split(): + assert move[0] in MOVES + assert move[1:] in ("", "'", "2") + + def test_scramble_is_deterministic_length(self) -> None: + """Scrambles of same length should all be that length.""" + for length in [5, 10, 15, 20, 25]: + scramble = generate_scramble(length=length) + assert len(scramble.split()) == length + + +class TestFaceColors: + """Face color definitions.""" + + def test_all_faces_have_colors(self) -> None: + for face in "UDFBLR": + assert face in FACE_COLORS + + def test_colors_are_valid_hex(self) -> None: + for color in FACE_COLORS.values(): + assert re.match(r"^#[0-9a-f]{6}$", color), f"Invalid color: {color}" diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..b5dcc37 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,181 @@ +"""Tests for JSON persistence of solve times.""" + +from __future__ import annotations + +import json +import os +from collections.abc import Generator +from pathlib import Path +from typing import Any + +import pytest + +from storage import get_data_path, load_times, save_times + + +@pytest.fixture +def temp_dir(tmp_path: Path) -> Generator[Path, None, None]: + """Provide a temporary directory for test data files.""" + yield tmp_path + + +@pytest.fixture +def sample_times() -> list[dict[str, Any]]: + """Provide sample solve data.""" + return [ + {"time": 12_345, "date": "2024-01-15 10:30:00", "scramble": "R U R' U'"}, + {"time": 15_678, "date": "2024-01-15 10:35:00", "scramble": "F R U R' U' F'"}, + {"time": 10_000, "date": "2024-01-15 10:40:00", "scramble": "U R U' L' U R' U' L"}, + ] + + +class TestGetDataPath: + """get_data_path should create the data directory and return correct paths.""" + + def test_returns_path_object(self) -> None: + result = get_data_path("test.json") + assert isinstance(result, Path) + + def test_default_filename(self) -> None: + result = get_data_path() + assert result.name == "times.json" + + def test_custom_filename(self) -> None: + result = get_data_path("custom.json") + assert result.name == "custom.json" + + def test_creates_directory(self, tmp_path: Path) -> None: + # Use a path in the temp directory to verify directory creation + test_dir = tmp_path / "test_data" + path = test_dir / "times.json" + path.parent.mkdir(parents=True, exist_ok=True) + assert path.parent.exists() + + +class TestSaveTimes: + """save_times should persist data correctly.""" + + def test_saves_to_file( + self, temp_dir: Path, sample_times: list[dict[str, Any]] + ) -> None: + path = temp_dir / "times.json" + save_times(sample_times, path) + assert path.exists() + + def test_file_contains_valid_json( + self, temp_dir: Path, sample_times: list[dict[str, Any]] + ) -> None: + path = temp_dir / "times.json" + save_times(sample_times, path) + with path.open(encoding="utf-8") as f: + data = json.load(f) + assert data == sample_times + + def test_empty_list(self, temp_dir: Path) -> None: + path = temp_dir / "times.json" + save_times([], path) + assert path.exists() + with path.open(encoding="utf-8") as f: + data = json.load(f) + assert data == [] + + def test_creates_parent_directory( + self, temp_dir: Path, sample_times: list[dict[str, Any]] + ) -> None: + nested = temp_dir / "subdir" / "nested" / "times.json" + save_times(sample_times, nested) + assert nested.exists() + + +class TestLoadTimes: + """load_times should read data correctly.""" + + def test_loads_saved_data( + self, temp_dir: Path, sample_times: list[dict[str, Any]] + ) -> None: + path = temp_dir / "times.json" + save_times(sample_times, path) + loaded = load_times(path) + assert loaded == sample_times + + def test_empty_file_returns_empty_list(self, temp_dir: Path) -> None: + path = temp_dir / "times.json" + # Create empty file + path.write_text("", encoding="utf-8") + loaded = load_times(path) + assert loaded == [] + + def test_missing_file_returns_empty_list(self, temp_dir: Path) -> None: + path = temp_dir / "nonexistent.json" + loaded = load_times(path) + assert loaded == [] + + def test_corrupt_json_returns_empty_list(self, temp_dir: Path) -> None: + path = temp_dir / "corrupt.json" + path.write_text("{invalid json!!!!}", encoding="utf-8") + loaded = load_times(path) + assert loaded == [] + + def test_invalid_structure_returns_empty_list(self, temp_dir: Path) -> None: + path = temp_dir / "invalid.json" + # Valid JSON but wrong structure (not a list of dicts with time/date) + with path.open("w", encoding="utf-8") as f: + json.dump({"not": "a list"}, f) + loaded = load_times(path) + assert loaded == [] + + def test_partial_invalid_entries_returns_empty_list(self, temp_dir: Path) -> None: + path = temp_dir / "partial.json" + # Valid JSON list but entries missing required fields + with path.open("w", encoding="utf-8") as f: + json.dump( + [{"time": 12345}, {"date": "2024-01-01"}], + f, + ) + loaded = load_times(path) + assert loaded == [] + + def test_round_trip_preserves_data( + self, temp_dir: Path, sample_times: list[dict[str, Any]] + ) -> None: + """Save, load, modify, save, load again — data should be preserved.""" + path = temp_dir / "roundtrip.json" + + # First save + save_times(sample_times, path) + loaded1 = load_times(path) + assert loaded1 == sample_times + + # Append a new solve + new_solve = {"time": 20_000, "date": "2024-01-15 11:00:00", "scramble": "R' D' R D"} + loaded1.append(new_solve) + save_times(loaded1, path) + + # Reload + loaded2 = load_times(path) + assert len(loaded2) == 4 + assert loaded2[-1] == new_solve + + def test_large_dataset(self, temp_dir: Path) -> None: + """Should handle hundreds of entries without issue.""" + path = temp_dir / "large.json" + times = [ + {"time": i * 100, "date": f"2024-01-01 00:00:{i:02d}", "scramble": "R U R'"} + for i in range(500) + ] + save_times(times, path) + loaded = load_times(path) + assert len(loaded) == 500 + assert loaded == times + + def test_file_permissions_error(self, temp_dir: Path) -> None: + """Should handle permission errors gracefully.""" + path = temp_dir / "readonly.json" + save_times([{"time": 1000, "date": "2024-01-01", "scramble": "R"}], path) + + # Make file read-only (on Windows this is different) + # Just test that the function handles OSError gracefully + if os.name != "nt": # Unix only + path.chmod(0o444) + loaded = load_times(path) + assert loaded is not None # Should return something, not crash diff --git a/tests/test_timer.py b/tests/test_timer.py new file mode 100644 index 0000000..8ef5130 --- /dev/null +++ b/tests/test_timer.py @@ -0,0 +1,193 @@ +"""Tests for the core timer state machine.""" + +from __future__ import annotations + +import time +from collections.abc import Generator + +import pytest + +from timer import SpeedCubeTimer, TimerPhase, format_time + + +@pytest.fixture +def timer() -> Generator[SpeedCubeTimer, None, None]: + """Provide a fresh timer instance for each test.""" + yield SpeedCubeTimer() + + +class TestTimerInitialState: + """Timer should start in IDLE phase with zero elapsed time.""" + + def test_initial_phase(self, timer: SpeedCubeTimer) -> None: + assert timer.phase == TimerPhase.IDLE + + def test_initial_elapsed(self, timer: SpeedCubeTimer) -> None: + assert timer.elapsed_ms == 0 + + def test_initial_running(self, timer: SpeedCubeTimer) -> None: + assert timer.running is False + + def test_initial_display(self, timer: SpeedCubeTimer) -> None: + assert timer.display_ms == 0 + + def test_initial_status_key(self, timer: SpeedCubeTimer) -> None: + assert timer.status_key == "idle" + + +class TestTimerInspection: + """Inspection phase: 15-second countdown.""" + + def test_start_inspection_sets_phase(self, timer: SpeedCubeTimer) -> None: + timer.start_inspection() + assert timer.phase == TimerPhase.INSPECTION + + def test_inspection_display_countdown(self, timer: SpeedCubeTimer) -> None: + timer.start_inspection() + timer.tick() + # After a tick, display should be close to 15_000 ms + assert 14_000 <= timer.display_ms <= 15_000 + + def test_inspection_transitions_to_grace(self, timer: SpeedCubeTimer) -> None: + timer.start_inspection() + # Simulate 15 seconds passing + timer._phase_start = time.time() - 16 # 16 seconds ago + timer.tick() + assert timer.phase == TimerPhase.GRACE + + def test_cancel_during_inspection(self, timer: SpeedCubeTimer) -> None: + timer.start_inspection() + timer.cancel() + assert timer.phase == TimerPhase.IDLE + assert timer.display_ms == 0 + + +class TestTimerGrace: + """Grace phase: 3-second window before READY.""" + + def test_grace_transitions_to_ready(self, timer: SpeedCubeTimer) -> None: + timer.start_inspection() + timer._phase_start = time.time() - 16 # Skip inspection + timer.tick() + assert timer.phase == TimerPhase.GRACE + + # Simulate 3 seconds passing + timer._phase_start = time.time() - 4 # 4 seconds ago + timer.tick() + assert timer.phase == TimerPhase.READY + + def test_cancel_during_grace(self, timer: SpeedCubeTimer) -> None: + timer.start_inspection() + timer._phase_start = time.time() - 16 # Skip to grace + timer.tick() + assert timer.phase == TimerPhase.GRACE + + timer.cancel() + assert timer.phase == TimerPhase.IDLE + + +class TestTimerSolve: + """Solve phase: timing a solve.""" + + def test_start_solve_from_ready(self, timer: SpeedCubeTimer) -> None: + # Manually set to READY (normally transitions from grace) + timer.phase = TimerPhase.READY + timer.start_solve() + assert timer.phase == TimerPhase.SOLVING + assert timer.running is True + + def test_solve_accumulates_time(self, timer: SpeedCubeTimer) -> None: + timer.phase = TimerPhase.READY + timer.start_solve() + # Simulate 1 second of solving + timer._start_time = time.time() - 1 + timer.tick() + assert 900 <= timer.elapsed_ms <= 1100 # ~1000ms + + def test_stop_solve_returns_elapsed(self, timer: SpeedCubeTimer) -> None: + timer.phase = TimerPhase.READY + timer.start_solve() + timer._start_time = time.time() - 2 # 2 seconds + timer.tick() + elapsed = timer.stop_solve() + assert 1900 <= elapsed <= 2100 + assert timer.phase == TimerPhase.IDLE + assert timer.running is False + + def test_reset_during_solve(self, timer: SpeedCubeTimer) -> None: + timer.phase = TimerPhase.READY + timer.start_solve() + timer.reset() + assert timer.phase == TimerPhase.IDLE + assert timer.running is False + assert timer.elapsed_ms == 0 + + +class TestTimerEdgeCases: + """Edge cases and boundary conditions.""" + + def test_double_start_inspection(self, timer: SpeedCubeTimer) -> None: + timer.start_inspection() + timer.start_inspection() # Should not crash + assert timer.phase == TimerPhase.INSPECTION + + def test_stop_solve_when_not_solving(self, timer: SpeedCubeTimer) -> None: + elapsed = timer.stop_solve() + assert elapsed == 0 + assert timer.phase == TimerPhase.IDLE + + def test_reset_when_idle(self, timer: SpeedCubeTimer) -> None: + timer.reset() # Should not crash + assert timer.phase == TimerPhase.IDLE + + def test_full_flow(self, timer: SpeedCubeTimer) -> None: + """Simulate a complete solve cycle.""" + timer.start_inspection() + assert timer.phase == TimerPhase.INSPECTION + + timer.cancel() + assert timer.phase == TimerPhase.IDLE + + timer.start_inspection() + timer._phase_start = time.time() - 16 + timer.tick() + assert timer.phase == TimerPhase.GRACE + + timer._phase_start = time.time() - 4 + timer.tick() + assert timer.phase == TimerPhase.READY + + timer.start_solve() + assert timer.phase == TimerPhase.SOLVING + + timer._start_time = time.time() - 3 + timer.tick() + elapsed = timer.stop_solve() + assert 2900 <= elapsed <= 3100 + assert timer.phase == TimerPhase.IDLE + + +class TestFormatTime: + """format_time utility function.""" + + def test_zero(self) -> None: + assert format_time(0) == "00:00.00" + + def test_seconds_only(self) -> None: + assert format_time(5_000) == "00:05.00" + + def test_minutes_and_seconds(self) -> None: + assert format_time(65_000) == "01:05.00" + + def test_centiseconds(self) -> None: + assert format_time(1_234) == "00:01.23" + + def test_large_value(self) -> None: + assert format_time(3_600_000) == "60:00.00" + + def test_negative_value(self) -> None: + assert format_time(-5_000) == "00:05.00" + + def test_rounding(self) -> None: + assert format_time(1_239) == "00:01.23" + assert format_time(1_240) == "00:01.24"