diff --git a/.gitignore b/.gitignore index 19fbd41..a499bb4 100644 --- a/.gitignore +++ b/.gitignore @@ -276,3 +276,4 @@ python_pkg/warsaw_districts/warszawa-dzielnice.geojson # Wikipedia cache (can be refreshed) python_pkg/polish_license_plates/.wikipedia_cache/ +python_pkg/cinema_planner/pasted_content.txt diff --git a/python_pkg/__init__.py b/python_pkg/__init__.py index e95ab4b..94bb6ce 100644 --- a/python_pkg/__init__.py +++ b/python_pkg/__init__.py @@ -1 +1,3 @@ """Top-level package marker for Python modules in this repo.""" + +# Import cinema_planner module diff --git a/python_pkg/cinema_planner/__init__.py b/python_pkg/cinema_planner/__init__.py new file mode 100644 index 0000000..a818e25 --- /dev/null +++ b/python_pkg/cinema_planner/__init__.py @@ -0,0 +1 @@ +# Cinema Planner package initialization diff --git a/python_pkg/cinema_planner/cinema_plan_2026-01-25.txt b/python_pkg/cinema_planner/cinema_plan_2026-01-25.txt new file mode 100644 index 0000000..6e8c13e --- /dev/null +++ b/python_pkg/cinema_planner/cinema_plan_2026-01-25.txt @@ -0,0 +1,176 @@ +Generated: 2026-01-25 +Movies considered: 27 +Buffer time: 0 minutes +Excluded genres: horror + +============================================================ + OPTIMAL CINEMA SCHEDULES - 2026-01-25 + 8 movies, 1482 possible combination(s) +============================================================ + +──────────────────────────────────────────────────────────── + OPTION 1: +──────────────────────────────────────────────────────────── + + 1. 10:00 - 11:50 Wielka Warszawska + Duration: 1h 50m (movie starts ~10:15) + [10 min break] + + 2. 12:00 - 13:27 Szybcy i sprytni + Duration: 1h 27m (movie starts ~12:15) + + 3. 13:20 - 14:57 Anzu. Kot duch + Duration: 1h 37m (movie starts ~13:35) + [3 min break] + + 4. 15:00 - 16:38 Psoty + Duration: 1h 38m (movie starts ~15:15) + [12 min break] + + 5. 16:50 - 18:33 Wysokie i niskie tony + Duration: 1h 43m (movie starts ~17:05) + + 6. 18:20 - 19:50 90 minut do wolności + Duration: 1h 30m (movie starts ~18:35) + [30 min break] + + 7. 20:20 - 22:25 Hamnet + Duration: 2h 5m (movie starts ~20:35) + [5 min break] + + 8. 22:30 - 24:10 Anakonda + Duration: 1h 40m (movie starts ~22:45) + +──────────────────────────────────────────────────────────── + OPTION 2: +──────────────────────────────────────────────────────────── + + 1. 10:00 - 11:50 Wielka Warszawska + Duration: 1h 50m (movie starts ~10:15) + [10 min break] + + 2. 12:00 - 13:27 Szybcy i sprytni + Duration: 1h 27m (movie starts ~12:15) + + 3. 13:20 - 14:57 Anzu. Kot duch + Duration: 1h 37m (movie starts ~13:35) + [13 min break] + + 4. 15:10 - 16:38 SpongeBob: Klątwa pirata + Duration: 1h 28m (movie starts ~15:25) + [12 min break] + + 5. 16:50 - 18:33 Wysokie i niskie tony + Duration: 1h 43m (movie starts ~17:05) + + 6. 18:20 - 19:50 90 minut do wolności + Duration: 1h 30m (movie starts ~18:35) + [30 min break] + + 7. 20:20 - 22:25 Hamnet + Duration: 2h 5m (movie starts ~20:35) + [5 min break] + + 8. 22:30 - 24:10 Anakonda + Duration: 1h 40m (movie starts ~22:45) + +──────────────────────────────────────────────────────────── + OPTION 3: +──────────────────────────────────────────────────────────── + + 1. 10:00 - 11:50 Wielka Warszawska + Duration: 1h 50m (movie starts ~10:15) + [10 min break] + + 2. 12:00 - 13:27 Szybcy i sprytni + Duration: 1h 27m (movie starts ~12:15) + [3 min break] + + 3. 13:30 - 15:18 Zwierzogród 2 + Duration: 1h 48m (movie starts ~13:45) + + 4. 15:10 - 16:38 SpongeBob: Klątwa pirata + Duration: 1h 28m (movie starts ~15:25) + [12 min break] + + 5. 16:50 - 18:33 Wysokie i niskie tony + Duration: 1h 43m (movie starts ~17:05) + + 6. 18:20 - 19:50 90 minut do wolności + Duration: 1h 30m (movie starts ~18:35) + [30 min break] + + 7. 20:20 - 22:25 Hamnet + Duration: 2h 5m (movie starts ~20:35) + [5 min break] + + 8. 22:30 - 24:10 Anakonda + Duration: 1h 40m (movie starts ~22:45) + +──────────────────────────────────────────────────────────── + OPTION 4: +──────────────────────────────────────────────────────────── + + 1. 10:00 - 11:50 Wielka Warszawska + Duration: 1h 50m (movie starts ~10:15) + [10 min break] + + 2. 12:00 - 13:27 Szybcy i sprytni + Duration: 1h 27m (movie starts ~12:15) + + 3. 13:20 - 14:57 Anzu. Kot duch + Duration: 1h 37m (movie starts ~13:35) + [3 min break] + + 4. 15:00 - 16:38 Psoty + Duration: 1h 38m (movie starts ~15:15) + [12 min break] + + 5. 16:50 - 18:33 Wysokie i niskie tony + Duration: 1h 43m (movie starts ~17:05) + + 6. 18:20 - 19:50 90 minut do wolności + Duration: 1h 30m (movie starts ~18:35) + [30 min break] + + 7. 20:20 - 22:25 Hamnet + Duration: 2h 5m (movie starts ~20:35) + + 8. 22:15 - 24:02 Dom dobry + Duration: 1h 47m (movie starts ~22:30) + +──────────────────────────────────────────────────────────── + OPTION 5: +──────────────────────────────────────────────────────────── + + 1. 10:00 - 11:50 Wielka Warszawska + Duration: 1h 50m (movie starts ~10:15) + [10 min break] + + 2. 12:00 - 13:27 Szybcy i sprytni + Duration: 1h 27m (movie starts ~12:15) + + 3. 13:20 - 14:57 Anzu. Kot duch + Duration: 1h 37m (movie starts ~13:35) + [13 min break] + + 4. 15:10 - 16:38 SpongeBob: Klątwa pirata + Duration: 1h 28m (movie starts ~15:25) + [12 min break] + + 5. 16:50 - 18:33 Wysokie i niskie tony + Duration: 1h 43m (movie starts ~17:05) + + 6. 18:20 - 19:50 90 minut do wolności + Duration: 1h 30m (movie starts ~18:35) + [30 min break] + + 7. 20:20 - 22:25 Hamnet + Duration: 2h 5m (movie starts ~20:35) + + 8. 22:15 - 24:02 Dom dobry + Duration: 1h 47m (movie starts ~22:30) + +──────────────────────────────────────────────────────────── + ... and 1477 more combinations + (use -n to show more, e.g., -n 10) diff --git a/python_pkg/cinema_planner/cinema_planner.py b/python_pkg/cinema_planner/cinema_planner.py new file mode 100755 index 0000000..2c4fd46 --- /dev/null +++ b/python_pkg/cinema_planner/cinema_planner.py @@ -0,0 +1,666 @@ +#!/usr/bin/env python3 +"""Cinema Day Planner - Maximize movies watched in a day. + +Supports: +- Cinema City HTML/PDF schedules (auto-parsed) +- Manual input format + +Usage: + ./cinema_planner.py schedule.html # Parse Cinema City HTML + ./cinema_planner.py schedule.pdf # Parse Cinema City PDF + ./cinema_planner.py -i # Interactive manual input + ./cinema_planner.py movies.txt # Manual format file +""" + +import argparse +from contextlib import redirect_stdout +from dataclasses import dataclass, field +from io import StringIO +from pathlib import Path +import re +import sys + +# Default genres to exclude (can be overridden with --all-genres) +DEFAULT_EXCLUDED_GENRES = {"horror"} + +# Ads duration before movie starts (Cinema City shows ~15 min of ads) +ADS_DURATION = 15 + + +@dataclass +class Movie: + name: str + start_times: list[int] + duration: int + genres: list[str] = field(default_factory=list) + + +@dataclass +class Screening: + movie: str + start: int # minutes from midnight + end: int # minutes from midnight + + def overlaps(self, other: "Screening", buffer: int = 0) -> bool: + # Account for ADS_DURATION grace period - you can arrive late and still catch the movie + # self ends, other starts: self.end vs other.start + ADS_DURATION (actual content start) + # other ends, self starts: other.end vs self.start + ADS_DURATION + return not ( + self.end + buffer <= other.start + ADS_DURATION + or other.end + buffer <= self.start + ADS_DURATION + ) + + def start_str(self) -> str: + return f"{self.start // 60:02d}:{self.start % 60:02d}" + + def end_str(self) -> str: + return f"{self.end // 60:02d}:{self.end % 60:02d}" + + +def parse_time(time_str: str) -> int: + """Parse time string like '18:20' to minutes from midnight.""" + time_str = time_str.strip().replace(".", ":") + match = re.match(r"(\d{1,2}):(\d{2})", time_str) + if not match: + raise ValueError(f"Invalid time format: {time_str}") + hours, minutes = int(match.group(1)), int(match.group(2)) + return hours * 60 + minutes + + +def parse_duration(duration_str: str) -> int: + """Parse duration like '1h 46m', '1:46', '106m', '110 min', etc.""" + duration_str = duration_str.strip().lower() + + # Try "X min" format (from Cinema City) + match = re.search(r"(\d+)\s*min", duration_str) + if match: + return int(match.group(1)) + + hours = 0 + minutes = 0 + + h_match = re.search(r"(\d+)\s*h", duration_str) + m_match = re.search(r"(\d+)\s*m(?!in)", duration_str) + + if h_match or m_match: + if h_match: + hours = int(h_match.group(1)) + if m_match: + minutes = int(m_match.group(1)) + return hours * 60 + minutes + + # Try "H:MM" format + match = re.match(r"(\d+):(\d{2})", duration_str) + if match: + return int(match.group(1)) * 60 + int(match.group(2)) + + # Try pure minutes + match = re.match(r"(\d+)", duration_str) + if match: + return int(match.group(1)) + + raise ValueError(f"Invalid duration format: {duration_str}") + + +def parse_manual_line(line: str) -> Movie | None: + """Parse a manual format line like 'Movie A, 18:20 or 20:50, 1h 46m'.""" + line = line.strip() + if not line or line.startswith("#"): + return None + + parts = line.split(",") + if len(parts) < 3: + raise ValueError(f"Invalid line format: {line}") + + movie = parts[0].strip() + times_str = parts[1].strip() + duration_str = ",".join(parts[2:]).strip() + + start_times = [] + for time_part in re.split(r"\s+or\s+", times_str, flags=re.IGNORECASE): + start_times.append(parse_time(time_part)) + + duration = parse_duration(duration_str) + + return Movie(movie, start_times, duration) + + +def extract_date_from_html(content: str) -> str | None: + """Extract schedule date from Cinema City HTML.""" + # Look for date in YYYY-MM-DD format + match = re.search(r"(202\d-\d{2}-\d{2})", content) + if match: + return match.group(1) + return None + + +def parse_cinema_city_html(filepath: str) -> tuple[list[Movie], str | None]: + """Parse Cinema City HTML schedule. Returns (movies, date).""" + with open(filepath, encoding="utf-8") as f: + content = f.read() + + movies = [] + schedule_date = extract_date_from_html(content) + + # Split content by movie sections + sections = re.split(r'class="row movie-row', content) + + for section in sections[1:]: # Skip first (before any movie) + # Get movie name + name_match = re.search(r'qb-movie-name">([^<]+)<', section) + if not name_match: + continue + movie_name = name_match.group(1).strip() + + # Get genres - they appear before the duration, separated by commas + # Pattern: class="mr-sm">Genre1, Genre2 ]*>([^<]+)<\s*span', section) + genres = [] + if genre_match: + genre_text = genre_match.group(1).strip() + genres = [g.strip() for g in genre_text.split(",") if g.strip()] + + # Get duration + duration_match = re.search(r"(\d+)\s*min", section) + if not duration_match: + continue + duration = int(duration_match.group(1)) + + # Get screening times - look for time buttons + times = re.findall(r'btn btn-primary btn-lg">\s*(\d{2}:\d{2})\s*<', section) + if not times: + # Try alternate pattern + times = re.findall(r">\s*(\d{2}:\d{2})\s*\(HTTPS://", section) + + if times: + start_times = [parse_time(t) for t in times] + # Remove duplicates while preserving order + start_times = list(dict.fromkeys(start_times)) + movies.append(Movie(movie_name, start_times, duration, genres)) + + # Deduplicate movies (same movie might appear multiple times) + seen = set() + unique_movies = [] + for movie in movies: + if movie.name not in seen: + seen.add(movie.name) + unique_movies.append(movie) + + return unique_movies, schedule_date + + +def parse_cinema_city_pdf(filepath: str) -> list[Movie]: + """Parse Cinema City PDF schedule by extracting text.""" + try: + import pdfplumber + except ImportError: + # Fallback to basic text extraction + return parse_cinema_city_pdf_basic(filepath) + + with pdfplumber.open(filepath) as pdf: + full_text = "" + for page in pdf.pages: + text = page.extract_text() + if text: + full_text += text + "\n" + + return parse_cinema_city_text(full_text) + + +def parse_cinema_city_pdf_basic(filepath: str) -> list[Movie]: + """Basic PDF parsing using PyMuPDF or falling back to subprocess.""" + try: + import fitz # PyMuPDF + + doc = fitz.open(filepath) + full_text = "" + for page in doc: + full_text += page.get_text() + "\n" + doc.close() + return parse_cinema_city_text(full_text) + except ImportError: + pass + + # Try pdftotext command + import subprocess + + try: + result = subprocess.run( + ["pdftotext", "-layout", filepath, "-"], + capture_output=True, + text=True, + check=True, + ) + return parse_cinema_city_text(result.stdout) + except (subprocess.CalledProcessError, FileNotFoundError): + print("Error: Install pdfplumber, PyMuPDF, or poppler-utils for PDF support") + print(" pip install pdfplumber") + print(" pip install pymupdf") + print(" pacman -S poppler") + sys.exit(1) + + +def parse_cinema_city_text(text: str) -> list[Movie]: + """Parse Cinema City schedule from extracted text.""" + movies = [] + lines = text.split("\n") + + current_movie = None + current_duration = None + current_times: list[int] = [] + + # Patterns for movie titles (all caps, usually) + movie_title_pattern = re.compile( + r"^([A-ZĄĆĘŁŃÓŚŹŻ][A-ZĄĆĘŁŃÓŚŹŻ0-9\s:,\.\-\!\?\(\)]+)$" + ) + + # Known movie indicators + duration_pattern = re.compile(r"(\d+)\s*min") + time_pattern = re.compile(r"\b(\d{1,2}:\d{2})\b") + + i = 0 + while i < len(lines): + line = lines[i].strip() + + # Check if this looks like a movie title + # Cinema City format: MOVIE TITLE on its own line, followed by genre | duration + if movie_title_pattern.match(line) and len(line) > 3: + # Save previous movie if exists + if current_movie and current_times: + movies.append( + Movie( + current_movie, + list(dict.fromkeys(current_times)), + current_duration or 120, + ) + ) + + # Check next lines for duration + current_movie = line.title() # Convert to title case + current_times = [] + current_duration = None + + # Look ahead for duration + for j in range(i + 1, min(i + 5, len(lines))): + dur_match = duration_pattern.search(lines[j]) + if dur_match: + current_duration = int(dur_match.group(1)) + break + + # Look for times in current line + if current_movie: + times_in_line = time_pattern.findall(line) + for t in times_in_line: + try: + current_times.append(parse_time(t)) + except ValueError: + pass + + i += 1 + + # Save last movie + if current_movie and current_times: + movies.append( + Movie( + current_movie, + list(dict.fromkeys(current_times)), + current_duration or 120, + ) + ) + + return movies + + +def find_best_schedule(movies: list[Movie], buffer: int) -> list[list[Screening]]: + """Find ALL schedules that maximize number of movies watched.""" + movie_screenings: list[list[Screening]] = [] + for movie in movies: + # Schedule times are accurate - arrive at start, leave at start + duration + # (ads are already factored into published times) + screenings = [ + Screening(movie.name, start, start + movie.duration) + for start in movie.start_times + ] + movie_screenings.append(screenings) + + best_count = 0 + all_best_schedules: list[list[Screening]] = [] + + def backtrack(movie_idx: int, current_schedule: list[Screening]): + nonlocal best_count, all_best_schedules + + if movie_idx == len(movie_screenings): + if len(current_schedule) > best_count: + best_count = len(current_schedule) + all_best_schedules = [current_schedule.copy()] + elif len(current_schedule) == best_count and best_count > 0: + all_best_schedules.append(current_schedule.copy()) + return + + # Pruning: can't beat the best + remaining = len(movie_screenings) - movie_idx + if len(current_schedule) + remaining < best_count: + return + + # Try each screening of current movie + for screening in movie_screenings[movie_idx]: + conflicts = any(screening.overlaps(s, buffer) for s in current_schedule) + if not conflicts: + current_schedule.append(screening) + backtrack(movie_idx + 1, current_schedule) + current_schedule.pop() + + # Also try skipping this movie + backtrack(movie_idx + 1, current_schedule) + + backtrack(0, []) + + # Sort each schedule by start time and return + return [sorted(schedule, key=lambda s: s.start) for schedule in all_best_schedules] + + +def print_single_schedule(schedule: list[Screening], schedule_num: int | None = None): + """Print a single schedule.""" + for i, screening in enumerate(schedule, 1): + duration = screening.end - screening.start + hours, mins = divmod(duration, 60) + # Movie starts ~15 min after listed time due to ads + actual_start = screening.start + ADS_DURATION + actual_start_str = f"{actual_start // 60:02d}:{actual_start % 60:02d}" + print( + f" {i}. {screening.start_str()} - {screening.end_str()} {screening.movie}" + ) + print(f" Duration: {hours}h {mins}m (movie starts ~{actual_start_str})") + if i < len(schedule): + gap = schedule[i].start - screening.end + if gap > 0: + print(f" [{gap} min break]") + print() + + +def print_schedules( + schedules: list[list[Screening]], + all_movies: list[str], + date: str | None = None, + max_display: int = 5, +): + """Print optimal schedules (up to max_display).""" + if not schedules or not schedules[0]: + print("No movies can be scheduled!") + return + + num_movies = len(schedules[0]) + num_schedules = len(schedules) + + print(f"\n{'=' * 60}") + if date: + print(f" OPTIMAL CINEMA SCHEDULES - {date}") + else: + print(" OPTIMAL CINEMA SCHEDULES") + print(f" {num_movies} movies, {num_schedules} possible combination(s)") + print(f"{'=' * 60}\n") + + display_count = min(num_schedules, max_display) + for idx, schedule in enumerate(schedules[:display_count], 1): + if num_schedules > 1: + print(f"{'─' * 60}") + print(f" OPTION {idx}:") + print(f"{'─' * 60}\n") + print_single_schedule(schedule) + + if num_schedules > display_count: + print(f"{'─' * 60}") + print(f" ... and {num_schedules - display_count} more combinations") + print(" (use -n to show more, e.g., -n 10)") + print() + + # Show skipped movies (from first schedule as reference) + scheduled_movies = {s.movie for s in schedules[0]} + skipped = [m for m in all_movies if m not in scheduled_movies] + if skipped and num_schedules == 1: + print(f"{'─' * 60}") + print(f" Skipped movies ({len(skipped)}):") + for movie in skipped: + print(f" - {movie}") + print() + + +def print_all_movies(movies: list[Movie], date: str | None = None): + """Print all parsed movies.""" + print(f"\n{'─' * 60}") + if date: + print(f" Parsed {len(movies)} movies for {date}:") + else: + print(f" Parsed {len(movies)} movies:") + print(f"{'─' * 60}") + for movie in movies: + times_str = ", ".join( + f"{t//60:02d}:{t%60:02d}" for t in sorted(movie.start_times) + ) + genre_str = f" [{', '.join(movie.genres)}]" if movie.genres else "" + print(f" {movie.name} ({movie.duration} min){genre_str}") + print(f" Times: {times_str}") + print() + + +def main(): + parser = argparse.ArgumentParser( + description="Plan your cinema day to watch as many movies as possible.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Supports Cinema City HTML/PDF schedules (auto-detected). + +Manual input format (one movie per line): + Movie Title, start_time1 [or start_time2 ...], duration + +Example: + Inception, 10:30 or 14:00 or 18:30, 2h 28m + The Matrix, 12:00 or 16:45, 2h 16m + """, + ) + parser.add_argument("input_file", nargs="?", help="Input file (HTML/PDF/TXT)") + parser.add_argument( + "-b", + "--buffer", + type=int, + default=0, + help="Buffer time between movies in minutes (default: 0)", + ) + parser.add_argument( + "-i", + "--interactive", + action="store_true", + help="Interactive mode - enter movies one by one", + ) + parser.add_argument( + "-l", + "--list", + action="store_true", + help="List all parsed movies without scheduling", + ) + parser.add_argument( + "-s", + "--select", + type=str, + help="Comma-separated list of movie names to include (partial match)", + ) + parser.add_argument( + "-x", + "--exclude", + type=str, + help="Comma-separated list of movie names to exclude (partial match)", + ) + parser.add_argument( + "-g", + "--exclude-genre", + type=str, + help="Comma-separated list of genres to exclude (e.g., 'Horror,Thriller')", + ) + parser.add_argument( + "--all-genres", + action="store_true", + help="Include all genres (disable default Horror exclusion)", + ) + parser.add_argument( + "-o", + "--output", + type=str, + help="Save schedule to file (default: cinema_plan_DATE.txt)", + ) + parser.add_argument( + "-n", + "--max-schedules", + type=int, + default=5, + help="Maximum number of schedule options to display (default: 5)", + ) + parser.add_argument( + "-m", + "--must-watch", + type=str, + help="Only show schedules containing this movie (partial match)", + ) + + args = parser.parse_args() + + movies = [] + schedule_date = None + + if args.interactive: + print("Enter movies (empty line to finish):") + print("Format: Title, start1 [or start2 ...], duration") + print("Example: Inception, 10:30 or 14:00, 2h 28m") + print() + while True: + try: + line = input("> ") + except EOFError: + break + if not line.strip(): + break + try: + result = parse_manual_line(line) + if result: + movies.append(result) + print(f" Added: {result.name}") + except ValueError as e: + print(f" Error: {e}") + elif args.input_file: + filepath = Path(args.input_file) + suffix = filepath.suffix.lower() + + print(f"Parsing: {filepath}") + + if suffix == ".html" or suffix == ".htm": + movies, schedule_date = parse_cinema_city_html(str(filepath)) + elif suffix == ".pdf": + movies = parse_cinema_city_pdf(str(filepath)) + else: + # Assume manual format + with open(filepath) as f: + for line in f: + try: + result = parse_manual_line(line) + if result: + movies.append(result) + except ValueError as e: + print(f"Warning: {e}", file=sys.stderr) + else: + print("Enter movies (Ctrl+D when done):") + for line in sys.stdin: + try: + result = parse_manual_line(line) + if result: + movies.append(result) + except ValueError as e: + print(f"Warning: {e}", file=sys.stderr) + + if not movies: + print("No movies found!") + sys.exit(1) + + # Filter movies if requested + if args.select: + select_terms = [t.strip().lower() for t in args.select.split(",")] + movies = [m for m in movies if any(t in m.name.lower() for t in select_terms)] + print(f"Selected {len(movies)} movies matching: {args.select}") + + if args.exclude: + exclude_terms = [t.strip().lower() for t in args.exclude.split(",")] + movies = [ + m for m in movies if not any(t in m.name.lower() for t in exclude_terms) + ] + print(f"After name exclusion: {len(movies)} movies") + + # Genre filtering + excluded_genres = set() + if not args.all_genres: + excluded_genres.update(DEFAULT_EXCLUDED_GENRES) + if args.exclude_genre: + excluded_genres.update(g.strip().lower() for g in args.exclude_genre.split(",")) + + if excluded_genres: + before_count = len(movies) + movies = [ + m for m in movies if not any(g.lower() in excluded_genres for g in m.genres) + ] + filtered_count = before_count - len(movies) + if filtered_count > 0: + print( + f"Excluded {filtered_count} movies by genre: {', '.join(sorted(excluded_genres))}" + ) + + if args.list: + print_all_movies(movies, schedule_date) + return + + print(f"\nOptimizing schedule for {len(movies)} movies...") + print(f"Buffer time between movies: {args.buffer} minutes") + + schedules = find_best_schedule(movies, args.buffer) + all_movie_names = [m.name for m in movies] + + # Filter schedules if must-watch movie specified + if args.must_watch: + must_watch_lower = args.must_watch.lower() + filtered = [ + s + for s in schedules + if any(must_watch_lower in screening.movie.lower() for screening in s) + ] + if filtered: + print( + f"Filtered to {len(filtered)} schedules containing '{args.must_watch}'" + ) + schedules = filtered + else: + print(f"Warning: No optimal schedules contain '{args.must_watch}'") + print("Showing all schedules instead.") + + # Capture output if saving to file + output_buffer = StringIO() + with redirect_stdout(output_buffer): + print_schedules(schedules, all_movie_names, schedule_date, args.max_schedules) + + schedule_output = output_buffer.getvalue() + print(schedule_output) # Still show in terminal + + # Save to file + if args.output or schedule_date: + if args.output: + output_file = Path(args.output) + else: + output_file = Path(f"cinema_plan_{schedule_date}.txt") + + with open(output_file, "w") as f: + f.write(f"Generated: {schedule_date or 'unknown date'}\n") + f.write(f"Movies considered: {len(movies)}\n") + f.write(f"Buffer time: {args.buffer} minutes\n") + if excluded_genres: + f.write(f"Excluded genres: {', '.join(sorted(excluded_genres))}\n") + f.write(schedule_output) + print(f"Schedule saved to: {output_file}") + + +if __name__ == "__main__": + main() diff --git a/python_pkg/cinema_planner/run.sh b/python_pkg/cinema_planner/run.sh new file mode 100755 index 0000000..046313b --- /dev/null +++ b/python_pkg/cinema_planner/run.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Run cinema planner with Cinema City schedule from Downloads + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +DOWNLOADS="$HOME/Downloads" + +# Show help if requested +if [[ "$1" == "-h" || "$1" == "--help" ]]; then + echo "Usage: ./run.sh [OPTIONS]" + echo "" + echo "Automatically finds Cinema City HTML schedule in ~/Downloads" + echo "" + echo "Options:" + echo " -l, --list List all movies without scheduling" + echo " -x, --exclude Exclude movies by name (comma-separated)" + echo " -g, --exclude-genre Exclude additional genres (comma-separated)" + echo " --all-genres Include all genres (disable Horror auto-exclusion)" + echo " -b, --buffer N Buffer time between movies (default: 0)" + echo " -m, --must-watch Only show schedules containing this movie" + echo " -n, --max-schedules Max number of schedule options to show (default: 5)" + echo "" + echo "Examples:" + echo " ./run.sh # Plan optimal schedule" + echo " ./run.sh -x 'Avatar,Sonic' # Exclude specific movies" + echo " ./run.sh -g 'Thriller,Dramat' # Also exclude Thriller and Drama" + echo " ./run.sh --all-genres # Include Horror movies" + echo " ./run.sh -m 'Hamnet' # Only schedules with Hamnet" + exit 0 +fi + +# Find the most recent Cinema City HTML file +HTML_FILE=$(find "$DOWNLOADS" -maxdepth 1 -name "Repertuar*.html" -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-) + +if [ -z "$HTML_FILE" ]; then + echo "No Cinema City schedule found in $DOWNLOADS" + echo "Download the schedule from cinema-city.pl first" + exit 1 +fi + +echo "Using: $HTML_FILE" +echo "" + +# Run the planner with any additional arguments passed to this script +"$SCRIPT_DIR/cinema_planner.py" "$HTML_FILE" "$@" diff --git a/python_pkg/screen_locker/screen_lock.py b/python_pkg/screen_locker/screen_lock.py index 9debf3f..bcd046b 100755 --- a/python_pkg/screen_locker/screen_lock.py +++ b/python_pkg/screen_locker/screen_lock.py @@ -24,6 +24,10 @@ MAX_REPS = 100 MAX_WEIGHT_KG = 500 SICK_LOCKOUT_SECONDS = 120 # 2 minutes wait when sick SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf") +# Table tennis minimum requirements (harder to fake) +MIN_TABLE_TENNIS_SETS = 15 +MIN_POINTS_PER_SET = 11 # Standard table tennis minimum points to win a set +TABLE_TENNIS_SUBMIT_DELAY = 60 # 60 seconds delay for table tennis # Helper script path (relative to this file) ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh" # State file to track sick day usage and original config values @@ -310,6 +314,39 @@ class ScreenLocker: _logger.warning("Failed to adjust shutdown time: %s", e) return False + def _adjust_shutdown_time_later(self) -> bool: + """Adjust shutdown schedule 1.5 hours later as workout reward. + + This moves the shutdown time later regardless of the initial time, + so working out even at 21:00 still makes sense. + + Returns True if successful, False otherwise. + """ + try: + # Read current config + config_values = self._read_shutdown_config() + if config_values is None: + return False + + mon_wed_hour, thu_sun_hour, morning_end_hour = config_values + + # Move shutdown times 1.5 hours (rounded to 2 hours) later + new_mon_wed = mon_wed_hour + 2 + new_thu_sun = thu_sun_hour + 2 + + # Cap at 23 (11 PM) to avoid going past midnight + new_mon_wed = min(23, new_mon_wed) + new_thu_sun = min(23, new_thu_sun) + + # Write new config with restore flag to allow later times + return self._write_shutdown_config( + new_mon_wed, new_thu_sun, morning_end_hour, restore=True + ) + + except (OSError, ValueError) as e: + _logger.warning("Failed to adjust shutdown time for workout: %s", e) + return False + def _sick_mode_used_today(self) -> bool: """Check if sick mode was already used today.""" if not SICK_DAY_STATE_FILE.exists(): @@ -509,17 +546,7 @@ class ScreenLocker: button_frame = tk.Frame(self.container, bg="#1a1a1a") button_frame.pack(pady=20) - running_btn = tk.Button( - button_frame, - text="RUNNING", - font=("Arial", 24, "bold"), - bg="#0066cc", - fg="white", - width=15, - command=self.ask_running_details, - cursor="hand2" if self.demo_mode else "", - ) - running_btn.pack(side="left", padx=20) + # Running option removed - too easy to fake strength_btn = tk.Button( button_frame, @@ -527,12 +554,24 @@ class ScreenLocker: font=("Arial", 24, "bold"), bg="#cc6600", fg="white", - width=15, + width=12, command=self.ask_strength_details, cursor="hand2" if self.demo_mode else "", ) strength_btn.pack(side="left", padx=20) + table_tennis_btn = tk.Button( + button_frame, + text="TABLE TENNIS", + font=("Arial", 20, "bold"), + bg="#00cc66", + fg="white", + width=12, + command=self.ask_table_tennis_details, + cursor="hand2" if self.demo_mode else "", + ) + table_tennis_btn.pack(side="left", padx=20) + def ask_running_details(self) -> None: """Display running workout input form.""" self.clear_container() @@ -790,6 +829,127 @@ class ScreenLocker: self.submit_command = self.verify_strength_data self.update_submit_timer() + def ask_table_tennis_details(self) -> None: + """Display table tennis workout input form.""" + self.clear_container() + self.workout_data["type"] = "table_tennis" + + title = tk.Label( + self.container, + text="Table Tennis Details", + font=("Arial", 36, "bold"), + fg="white", + bg="#1a1a1a", + ) + title.pack(pady=20) + + # Instructions/Requirements + requirements = tk.Label( + self.container, + text=( + f"Requirements: Minimum {MIN_TABLE_TENNIS_SETS} sets, " + f"each set needs at least {MIN_POINTS_PER_SET} total points" + ), + font=("Arial", 14), + fg="#aaaaaa", + bg="#1a1a1a", + ) + requirements.pack(pady=5) + + # Duration + duration_frame = tk.Frame(self.container, bg="#1a1a1a") + duration_frame.pack(pady=10) + tk.Label( + duration_frame, + text="Duration (minutes):", + font=("Arial", 20), + fg="white", + bg="#1a1a1a", + ).pack(side="left", padx=10) + self.tt_duration_entry = tk.Entry(duration_frame, font=("Arial", 20), width=10) + self.tt_duration_entry.pack(side="left", padx=10) + + # Sets played + sets_frame = tk.Frame(self.container, bg="#1a1a1a") + sets_frame.pack(pady=10) + tk.Label( + sets_frame, + text="Sets played:", + font=("Arial", 20), + fg="white", + bg="#1a1a1a", + ).pack(side="left", padx=10) + self.tt_sets_entry = tk.Entry(sets_frame, font=("Arial", 20), width=10) + self.tt_sets_entry.pack(side="left", padx=10) + + # Points won + won_frame = tk.Frame(self.container, bg="#1a1a1a") + won_frame.pack(pady=10) + tk.Label( + won_frame, + text="Points won:", + font=("Arial", 20), + fg="white", + bg="#1a1a1a", + ).pack(side="left", padx=10) + self.tt_won_entry = tk.Entry(won_frame, font=("Arial", 20), width=10) + self.tt_won_entry.pack(side="left", padx=10) + + # Points lost + lost_frame = tk.Frame(self.container, bg="#1a1a1a") + lost_frame.pack(pady=10) + tk.Label( + lost_frame, + text="Points lost:", + font=("Arial", 20), + fg="white", + bg="#1a1a1a", + ).pack(side="left", padx=10) + self.tt_lost_entry = tk.Entry(lost_frame, font=("Arial", 20), width=10) + self.tt_lost_entry.pack(side="left", padx=10) + + # Timer countdown label + self.timer_label = tk.Label( + self.container, text="", font=("Arial", 16), fg="#ffaa00", bg="#1a1a1a" + ) + self.timer_label.pack(pady=10) + + self.submit_btn = tk.Button( + self.container, + text="SUBMIT (locked)", + font=("Arial", 24, "bold"), + bg="#666666", + fg="white", + width=15, + state="disabled", + cursor="hand2" if self.demo_mode else "", + ) + self.submit_btn.pack(pady=10) + + # Back button + back_btn = tk.Button( + self.container, + text="← BACK", + font=("Arial", 18), + bg="#666666", + fg="white", + width=15, + command=self.ask_workout_type, + cursor="hand2" if self.demo_mode else "", + ) + back_btn.pack(pady=10) + + # Start 60 second timer (increased from 30) + self.submit_unlock_time = TABLE_TENNIS_SUBMIT_DELAY + self.entries_to_check = [ + self.tt_duration_entry, + self.tt_sets_entry, + self.tt_won_entry, + self.tt_lost_entry, + ] + self.submit_command = self.verify_table_tennis_data + self.update_submit_timer() + def _parse_reps(self, reps_raw: list[str]) -> list[list[int]]: """Parse reps input - can be single number or variable reps like '12+11+12'.""" reps: list[list[int]] = [] @@ -883,6 +1043,196 @@ class ScreenLocker: except ValueError: self.show_error("Please enter valid data in correct format") + def verify_table_tennis_data(self) -> None: + """Validate table tennis workout data and unlock if valid.""" + try: + duration = float(self.tt_duration_entry.get()) + sets_played = int(self.tt_sets_entry.get()) + points_won = int(self.tt_won_entry.get()) + points_lost = int(self.tt_lost_entry.get()) + + # Basic validation + if duration <= 0: + self.show_error("Duration must be greater than 0 minutes") + return + if sets_played <= 0: + self.show_error("Sets played must be greater than 0") + return + if points_won < 0 or points_lost < 0: + self.show_error("Points cannot be negative") + return + if points_won + points_lost == 0: + self.show_error("You must have played some points") + return + + # Stricter validation - minimum sets requirement + if sets_played < MIN_TABLE_TENNIS_SETS: + self.show_error( + f"Minimum {MIN_TABLE_TENNIS_SETS} sets required for a valid workout" + ) + return + + # Mathematical cross-check: total_points >= sets_played * MIN_POINTS_PER_SET + total_points = points_won + points_lost + min_expected_points = sets_played * MIN_POINTS_PER_SET + if total_points < min_expected_points: + self.show_error( + f"Invalid data: {sets_played} sets needs " + f"at least {min_expected_points} total points " + f"(min {MIN_POINTS_PER_SET} per set). " + f"You entered {total_points}." + ) + return + + # Reasonable duration check: at least 2 minutes per set + min_expected_duration = sets_played * 2 + if duration < min_expected_duration: + self.show_error( + f"Duration too short: {sets_played} sets should " + f"take at least {min_expected_duration} minutes" + ) + return + + # Ask verification question about the data + self.ask_table_tennis_verification( + duration, sets_played, points_won, points_lost + ) + + except ValueError: + self.show_error("Please enter valid numbers") + + def ask_table_tennis_verification( + self, duration: float, sets_played: int, points_won: int, points_lost: int + ) -> None: + """Ask a math verification question about the entered data.""" + import random + + self.clear_container() + + # Store data for later submission + self._pending_tt_data = { + "duration": duration, + "sets_played": sets_played, + "points_won": points_won, + "points_lost": points_lost, + } + + # Generate a random verification question based on their data + total_points = points_won + points_lost + question_types = [ + ( + "total_points", + "What is the TOTAL number of points played? (won + lost)", + total_points, + ), + ( + "avg_per_set", + "Rounded DOWN: what is the average points per set? (total ÷ sets)", + total_points // sets_played, + ), + ( + "point_diff", + "What is the difference between won and lost points? (won - lost)", + abs(points_won - points_lost), + ), + ] + + question_type, question_text, expected_answer = random.choice(question_types) + self._tt_expected_answer = expected_answer + self._tt_question_type = question_type + + title = tk.Label( + self.container, + text="🔢 Verification Question", + font=("Arial", 30, "bold"), + fg="white", + bg="#1a1a1a", + ) + title.pack(pady=20) + + info = tk.Label( + self.container, + text=( + f"Based on your data: {sets_played} sets, " + f"{points_won} won, {points_lost} lost" + ), + font=("Arial", 16), + fg="#aaaaaa", + bg="#1a1a1a", + ) + info.pack(pady=10) + + question = tk.Label( + self.container, + text=question_text, + font=("Arial", 20, "bold"), + fg="#ffaa00", + bg="#1a1a1a", + ) + question.pack(pady=20) + + answer_frame = tk.Frame(self.container, bg="#1a1a1a") + answer_frame.pack(pady=10) + tk.Label( + answer_frame, + text="Your answer:", + font=("Arial", 20), + fg="white", + bg="#1a1a1a", + ).pack(side="left", padx=10) + self.tt_verify_entry = tk.Entry(answer_frame, font=("Arial", 20), width=10) + self.tt_verify_entry.pack(side="left", padx=10) + self.tt_verify_entry.focus_set() + + submit_btn = tk.Button( + self.container, + text="VERIFY & SUBMIT", + font=("Arial", 24, "bold"), + bg="#00aa00", + fg="white", + width=15, + command=self.verify_table_tennis_answer, + cursor="hand2" if self.demo_mode else "", + ) + submit_btn.pack(pady=20) + + # Back button + back_btn = tk.Button( + self.container, + text="← BACK", + font=("Arial", 18), + bg="#666666", + fg="white", + width=15, + command=self.ask_table_tennis_details, + cursor="hand2" if self.demo_mode else "", + ) + back_btn.pack(pady=10) + + def verify_table_tennis_answer(self) -> None: + """Check the verification answer and unlock if correct.""" + try: + user_answer = int(self.tt_verify_entry.get()) + if user_answer != self._tt_expected_answer: + self.show_error( + f"Incorrect! The answer was {self._tt_expected_answer}. " + "Did you enter accurate data?" + ) + # Go back to input form + self.root.after(2000, self.ask_table_tennis_details) + return + + # Answer correct - store data and unlock + data = self._pending_tt_data + self.workout_data["duration_minutes"] = str(data["duration"]) + self.workout_data["sets_played"] = str(data["sets_played"]) + self.workout_data["points_won"] = str(data["points_won"]) + self.workout_data["points_lost"] = str(data["points_lost"]) + self.unlock_screen() + + except ValueError: + self.show_error("Please enter a valid number") + def update_submit_timer(self) -> None: """Update countdown timer and check if submit can be enabled.""" # Check if widgets still exist (user might have clicked back) @@ -974,6 +1324,14 @@ class ScreenLocker: # Save workout data to log self.save_workout_log() + # Adjust shutdown time later for actual workouts (not sick days) + shutdown_adjusted = False + workout_type = self.workout_data.get("type", "") + if workout_type in ("running", "strength", "table_tennis"): + shutdown_adjusted = self._adjust_shutdown_time_later() + if shutdown_adjusted: + _logger.info("Shutdown time moved 1.5 hours later as workout reward") + self.clear_container() success_label = tk.Label( @@ -985,6 +1343,17 @@ class ScreenLocker: ) success_label.pack(pady=30) + # Show shutdown adjustment status + if shutdown_adjusted: + bonus_label = tk.Label( + self.container, + text="Shutdown time +1.5h later! 🎁", + font=("Arial", 24), + fg="#ffaa00", + bg="#1a1a1a", + ) + bonus_label.pack(pady=10) + unlock_label = tk.Label( self.container, text="Screen Unlocked!", diff --git a/python_pkg/screen_locker/tests/test_screen_lock.py b/python_pkg/screen_locker/tests/test_screen_lock.py index 475a6f9..fb117aa 100644 --- a/python_pkg/screen_locker/tests/test_screen_lock.py +++ b/python_pkg/screen_locker/tests/test_screen_lock.py @@ -55,6 +55,15 @@ class StrengthData(NamedTuple): total_weight: str +class TableTennisData(NamedTuple): + """Table tennis workout data for tests.""" + + duration: str + sets_played: str + points_won: str + points_lost: str + + @pytest.fixture def mock_tk() -> Generator[MagicMock]: """Mock tkinter module for testing without display.""" @@ -128,6 +137,18 @@ def setup_strength_entries(locker: ScreenLocker, data: StrengthData) -> None: locker.total_weight_entry.get.return_value = data.total_weight +def setup_table_tennis_entries(locker: ScreenLocker, data: TableTennisData) -> None: + """Set up mock table tennis entry widgets.""" + locker.tt_duration_entry = MagicMock() + locker.tt_duration_entry.get.return_value = data.duration + locker.tt_sets_entry = MagicMock() + locker.tt_sets_entry.get.return_value = data.sets_played + locker.tt_won_entry = MagicMock() + locker.tt_won_entry.get.return_value = data.points_won + locker.tt_lost_entry = MagicMock() + locker.tt_lost_entry.get.return_value = data.points_lost + + class TestConstants: """Tests for module constants.""" @@ -710,6 +731,109 @@ class TestVerifyStrengthData: assert "valid data" in locker.show_error.call_args[0][0] +class TestVerifyTableTennisData: + """Tests for verify_table_tennis_data method.""" + + def test_valid_table_tennis_data( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test valid table tennis data unlocks screen.""" + locker = create_locker(mock_tk, tmp_path) + setup_table_tennis_entries(locker, TableTennisData("60", "3", "21", "15")) + locker.unlock_screen = MagicMock() # type: ignore[method-assign] + + locker.verify_table_tennis_data() + + locker.unlock_screen.assert_called_once() + assert locker.workout_data["duration_minutes"] == "60.0" + assert locker.workout_data["sets_played"] == "3" + assert locker.workout_data["points_won"] == "21" + assert locker.workout_data["points_lost"] == "15" + + def test_invalid_duration_zero( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test duration <= 0 is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_table_tennis_entries(locker, TableTennisData("0", "3", "21", "15")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_table_tennis_data() + + locker.show_error.assert_called_once() + assert "greater than 0" in locker.show_error.call_args[0][0] + + def test_invalid_sets_zero( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test sets <= 0 is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_table_tennis_entries(locker, TableTennisData("60", "0", "21", "15")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_table_tennis_data() + + locker.show_error.assert_called_once() + assert "greater than 0" in locker.show_error.call_args[0][0] + + def test_invalid_points_negative( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test negative points are rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_table_tennis_entries(locker, TableTennisData("60", "3", "-1", "15")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_table_tennis_data() + + locker.show_error.assert_called_once() + assert "cannot be negative" in locker.show_error.call_args[0][0] + + def test_invalid_no_points_played( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test zero total points is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_table_tennis_entries(locker, TableTennisData("60", "3", "0", "0")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_table_tennis_data() + + locker.show_error.assert_called_once() + assert "played some points" in locker.show_error.call_args[0][0] + + def test_invalid_number_format( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test invalid format is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_table_tennis_entries(locker, TableTennisData("abc", "3", "21", "15")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_table_tennis_data() + + locker.show_error.assert_called_once() + assert "valid numbers" in locker.show_error.call_args[0][0] + + class TestUITransitions: """Tests for UI state transitions.""" @@ -1142,6 +1266,62 @@ class TestAskStrengthDetails: locker.update_submit_timer.assert_called_once() +class TestAskTableTennisDetails: + """Tests for ask_table_tennis_details method.""" + + def test_ask_table_tennis_details_sets_workout_type( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test ask_table_tennis_details sets workout type to table_tennis.""" + locker = create_locker(mock_tk, tmp_path) + locker.clear_container = MagicMock() # type: ignore[method-assign] + locker.update_submit_timer = MagicMock() # type: ignore[method-assign] + + locker.ask_table_tennis_details() + + assert locker.workout_data["type"] == "table_tennis" + locker.clear_container.assert_called_once() + + def test_ask_table_tennis_details_creates_entry_fields( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test ask_table_tennis_details creates entry fields.""" + locker = create_locker(mock_tk, tmp_path) + locker.clear_container = MagicMock() # type: ignore[method-assign] + locker.update_submit_timer = MagicMock() # type: ignore[method-assign] + + locker.ask_table_tennis_details() + + # Verify Entry fields were created + mock_tk.Entry.assert_called() + assert hasattr(locker, "tt_duration_entry") + assert hasattr(locker, "tt_sets_entry") + assert hasattr(locker, "tt_won_entry") + assert hasattr(locker, "tt_lost_entry") + + def test_ask_table_tennis_details_sets_timer( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test ask_table_tennis_details initializes submit timer.""" + locker = create_locker(mock_tk, tmp_path) + locker.clear_container = MagicMock() # type: ignore[method-assign] + locker.update_submit_timer = MagicMock() # type: ignore[method-assign] + + locker.ask_table_tennis_details() + + assert locker.submit_unlock_time == 30 + locker.update_submit_timer.assert_called_once() + + class TestAskWorkoutDone: """Tests for ask_workout_done method.""" @@ -1160,3 +1340,194 @@ class TestAskWorkoutDone: locker.clear_container.assert_called_once() mock_tk.Label.assert_called() mock_tk.Button.assert_called() + + +class TestAdjustShutdownTimeLater: + """Tests for _adjust_shutdown_time_later method.""" + + def test_adjust_shutdown_time_later_success( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test _adjust_shutdown_time_later adds hours successfully.""" + locker = create_locker(mock_tk, tmp_path) + locker._read_shutdown_config = MagicMock( # type: ignore[method-assign] + return_value=(21, 22, 8) + ) + locker._write_shutdown_config = MagicMock( # type: ignore[method-assign] + return_value=True + ) + + result = locker._adjust_shutdown_time_later() + + assert result is True + locker._write_shutdown_config.assert_called_once_with(23, 23, 8, restore=True) + + def test_adjust_shutdown_time_later_caps_at_23( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test _adjust_shutdown_time_later caps hours at 23.""" + locker = create_locker(mock_tk, tmp_path) + locker._read_shutdown_config = MagicMock( # type: ignore[method-assign] + return_value=(22, 23, 8) + ) + locker._write_shutdown_config = MagicMock( # type: ignore[method-assign] + return_value=True + ) + + result = locker._adjust_shutdown_time_later() + + assert result is True + # 22+2=24 capped to 23, 23+2=25 capped to 23 + locker._write_shutdown_config.assert_called_once_with(23, 23, 8, restore=True) + + def test_adjust_shutdown_time_later_no_config( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test _adjust_shutdown_time_later returns False if config missing.""" + locker = create_locker(mock_tk, tmp_path) + locker._read_shutdown_config = MagicMock( # type: ignore[method-assign] + return_value=None + ) + + result = locker._adjust_shutdown_time_later() + + assert result is False + + def test_adjust_shutdown_time_later_oserror( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test _adjust_shutdown_time_later handles OSError.""" + locker = create_locker(mock_tk, tmp_path) + locker._read_shutdown_config = MagicMock( # type: ignore[method-assign] + side_effect=OSError("permission denied") + ) + + result = locker._adjust_shutdown_time_later() + + assert result is False + + +class TestUnlockScreenShutdownAdjustment: + """Tests for unlock_screen shutdown time adjustment.""" + + def test_unlock_screen_adjusts_for_running( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test unlock_screen adjusts shutdown for running workout.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "running"} + locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] + return_value=True + ) + + locker.unlock_screen() + + locker._adjust_shutdown_time_later.assert_called_once() + + def test_unlock_screen_adjusts_for_strength( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test unlock_screen adjusts shutdown for strength workout.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "strength"} + locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] + return_value=True + ) + + locker.unlock_screen() + + locker._adjust_shutdown_time_later.assert_called_once() + + def test_unlock_screen_adjusts_for_table_tennis( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test unlock_screen adjusts shutdown for table tennis workout.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "table_tennis"} + locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] + return_value=True + ) + + locker.unlock_screen() + + locker._adjust_shutdown_time_later.assert_called_once() + + def test_unlock_screen_skips_adjustment_for_sick_day( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test unlock_screen does not adjust for sick day.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "sick_day"} + locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] + return_value=True + ) + + locker.unlock_screen() + + locker._adjust_shutdown_time_later.assert_not_called() + + def test_unlock_screen_skips_adjustment_no_type( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test unlock_screen does not adjust when no workout type.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {} + locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] + return_value=True + ) + + locker.unlock_screen() + + locker._adjust_shutdown_time_later.assert_not_called() + + def test_unlock_screen_handles_adjustment_failure( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, # noqa: ARG002 + tmp_path: Path, + ) -> None: + """Test unlock_screen continues when adjustment fails.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "running"} + locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] + return_value=False + ) + + # Should not raise, should continue with unlock + locker.unlock_screen() + + locker._adjust_shutdown_time_later.assert_called_once() + locker.root.after.assert_called() # type: ignore[attr-defined]