mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 16:03:07 +02:00
- Add comprehensive tests for all packages (3572 tests, 100% branch coverage) - Split oversized test files to stay under 500-line limit - Add per-file ruff ignores for test-appropriate suppressions - Fix _cache_decks.py to properly convert JSON lists to tuples - Add session-scoped conftest fixture for logging handler cleanup (Python 3.14) - Update ruff pre-commit hook to v0.15.2 - Add codespell ignore words for test data - Add generated output files to .gitignore
200 lines
6.4 KiB
Python
200 lines
6.4 KiB
Python
"""Scheduling algorithm and display formatting for cinema plans."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import sys
|
|
from typing import TYPE_CHECKING, TextIO
|
|
|
|
if TYPE_CHECKING:
|
|
from python_pkg.cinema_planner._cinema_parsing import Movie
|
|
|
|
# Ads duration before movie starts (Cinema City shows ~15 min of ads)
|
|
ADS_DURATION = 15
|
|
|
|
_SEPARATOR_WIDTH = 60
|
|
|
|
|
|
@dataclass
|
|
class Screening:
|
|
"""A specific screening of a movie at a particular time."""
|
|
|
|
movie: str
|
|
start: int # minutes from midnight
|
|
end: int # minutes from midnight
|
|
|
|
def overlaps(self, other: Screening, buffer: int = 0) -> bool:
|
|
"""Check if this screening overlaps with another, considering buffer."""
|
|
# Account for ADS_DURATION grace period
|
|
return not (
|
|
self.end + buffer <= other.start + ADS_DURATION
|
|
or other.end + buffer <= self.start + ADS_DURATION
|
|
)
|
|
|
|
def start_str(self) -> str:
|
|
"""Format start time as HH:MM."""
|
|
return f"{self.start // 60:02d}:{self.start % 60:02d}"
|
|
|
|
def end_str(self) -> str:
|
|
"""Format end time as HH:MM."""
|
|
return f"{self.end // 60:02d}:{self.end % 60:02d}"
|
|
|
|
|
|
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]] = [
|
|
[
|
|
Screening(movie.name, start, start + movie.duration)
|
|
for start in movie.start_times
|
|
]
|
|
for movie in movies
|
|
]
|
|
|
|
best_count = 0
|
|
all_best_schedules: list[list[Screening]] = []
|
|
|
|
def _backtrack(
|
|
movie_idx: int,
|
|
current_schedule: list[Screening],
|
|
) -> None:
|
|
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 _format_single_schedule(
|
|
schedule: list[Screening],
|
|
output: TextIO,
|
|
) -> None:
|
|
"""Format a single schedule to the output stream."""
|
|
for i, screening in enumerate(schedule, 1):
|
|
duration = screening.end - screening.start
|
|
hours, mins = divmod(duration, 60)
|
|
actual_start = screening.start + ADS_DURATION
|
|
actual_start_str = f"{actual_start // 60:02d}:{actual_start % 60:02d}"
|
|
output.write(
|
|
f" {i}. {screening.start_str()} - "
|
|
f"{screening.end_str()} {screening.movie}\n"
|
|
)
|
|
output.write(
|
|
f" Duration: {hours}h {mins}m (movie starts ~{actual_start_str})\n"
|
|
)
|
|
if i < len(schedule):
|
|
gap = schedule[i].start - screening.end
|
|
if gap > 0:
|
|
output.write(f" [{gap} min break]\n")
|
|
output.write("\n")
|
|
|
|
|
|
def _format_schedules(
|
|
schedules: list[list[Screening]],
|
|
all_movies: list[str],
|
|
date: str | None = None,
|
|
max_display: int = 5,
|
|
*,
|
|
output: TextIO | None = None,
|
|
) -> None:
|
|
"""Format optimal schedules to the output stream."""
|
|
if output is None:
|
|
output = sys.stdout
|
|
|
|
sep = "=" * _SEPARATOR_WIDTH
|
|
thin_sep = "\u2500" * _SEPARATOR_WIDTH
|
|
|
|
if not schedules or not schedules[0]:
|
|
output.write("No movies can be scheduled!\n")
|
|
return
|
|
|
|
num_movies = len(schedules[0])
|
|
num_schedules = len(schedules)
|
|
|
|
output.write(f"\n{sep}\n")
|
|
if date:
|
|
output.write(f" OPTIMAL CINEMA SCHEDULES - {date}\n")
|
|
else:
|
|
output.write(" OPTIMAL CINEMA SCHEDULES\n")
|
|
output.write(f" {num_movies} movies, {num_schedules} possible combination(s)\n")
|
|
output.write(f"{sep}\n\n")
|
|
|
|
display_count = min(num_schedules, max_display)
|
|
for idx, schedule in enumerate(schedules[:display_count], 1):
|
|
if num_schedules > 1:
|
|
output.write(f"{thin_sep}\n")
|
|
output.write(f" OPTION {idx}:\n")
|
|
output.write(f"{thin_sep}\n\n")
|
|
_format_single_schedule(schedule, output)
|
|
|
|
if num_schedules > display_count:
|
|
output.write(f"{thin_sep}\n")
|
|
output.write(f" ... and {num_schedules - display_count} more combinations\n")
|
|
output.write(" (use -n to show more, e.g., -n 10)\n")
|
|
output.write("\n")
|
|
|
|
# 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:
|
|
output.write(f"{thin_sep}\n")
|
|
output.write(f" Skipped movies ({len(skipped)}):\n")
|
|
for movie in skipped:
|
|
output.write(f" - {movie}\n")
|
|
output.write("\n")
|
|
|
|
|
|
def _format_all_movies(
|
|
movies: list[Movie],
|
|
date: str | None = None,
|
|
*,
|
|
output: TextIO | None = None,
|
|
) -> None:
|
|
"""Format all parsed movies to the output stream."""
|
|
if output is None:
|
|
output = sys.stdout
|
|
|
|
thin_sep = "\u2500" * _SEPARATOR_WIDTH
|
|
|
|
output.write(f"\n{thin_sep}\n")
|
|
if date:
|
|
output.write(f" Parsed {len(movies)} movies for {date}:\n")
|
|
else:
|
|
output.write(f" Parsed {len(movies)} movies:\n")
|
|
output.write(f"{thin_sep}\n")
|
|
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 ""
|
|
output.write(f" {movie.name} ({movie.duration} min){genre_str}\n")
|
|
output.write(f" Times: {times_str}\n")
|
|
output.write("\n")
|