mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 11:23:10 +02:00
feat(screen_locker): harden table tennis verification, remove running option
- Remove 'Running' workout option (too easy to fake) - Add MIN_TABLE_TENNIS_SETS=15 minimum requirement - Add MIN_POINTS_PER_SET=11 mathematical cross-check - Add TABLE_TENNIS_SUBMIT_DELAY=60 (increased from 30) - Add verification question before unlock (total points/avg/diff) - Require minimum duration per set (2 min/set)
This commit is contained in:
parent
a58568faa6
commit
f557c22e7c
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
|
||||
|
||||
@ -1 +1,3 @@
|
||||
"""Top-level package marker for Python modules in this repo."""
|
||||
|
||||
# Import cinema_planner module
|
||||
|
||||
1
python_pkg/cinema_planner/__init__.py
Normal file
1
python_pkg/cinema_planner/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Cinema Planner package initialization
|
||||
176
python_pkg/cinema_planner/cinema_plan_2026-01-25.txt
Normal file
176
python_pkg/cinema_planner/cinema_plan_2026-01-25.txt
Normal file
@ -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)
|
||||
666
python_pkg/cinema_planner/cinema_planner.py
Executable file
666
python_pkg/cinema_planner/cinema_planner.py
Executable file
@ -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 <span
|
||||
genre_match = re.search(r'class="mr-sm"[^>]*>([^<]+)<\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()
|
||||
44
python_pkg/cinema_planner/run.sh
Executable file
44
python_pkg/cinema_planner/run.sh
Executable file
@ -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" "$@"
|
||||
@ -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!",
|
||||
|
||||
@ -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]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user